Compare commits

...
Author SHA1 Message Date
james.maes 5f0dbae0be Merge pull request 'fix(admin): decide a station delete's refusal under the lock, before revoking' (#40) from fix/delete-refusal-under-lock into main
build / maven (push) Successful in 2m42s
maven / build (push) Successful in 2m42s
2026-11-12 15:11:14 +00:00
james.maesandClaude Fable 5.2 d7c7c203c3 fix(delete): write names only onto jobs that lack one; correct the changelog on the old backfill
build / maven (push) Successful in 2m22s
maven / build (push) Successful in 2m22s
Review of PR 40: the name backfill at delete rewrote every job of the station
while holding the tenant's admission lock, and the changelog called the old
startup backfill a no-op where it nulled a queue name.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:30:50 -06:00
james.maesandClaude Fable 5.2 b063f9a55f fix(admin): decide a station delete's refusal under the lock, before revoking
build / maven (push) Successful in 2m13s
maven / build (push) Successful in 2m13s
deleteDevice decided "connected and printing" from a read without a lock,
revoked the station, and only then locked and read again. A revoked station
never counts as connected, so the locked read could not refuse: a station
that claimed a job between the first read and the revocation was deleted and
the job closed as OUTCOME_UNKNOWN although the station had its content.

The delete is now one transaction: tenant admission lock, then the station's
row lock (the lock a claim, a job event and a heartbeat take first), the
refusal, the REVOKE control on the same connection, the job closing and the
row deletes, one commit. A refused or failed delete changes nothing; the
station is no longer left revoked when the delete's transaction fails.
PrintingDeviceControls.action gains an overload that runs inside the
caller's transaction. deletePrinter already decided under the station's row
lock and is unchanged.

Also: the printer approval route returns "Printer was removed; share it from
the device again before changing its approval" again (RC.3 changed an HTTP
API text); the startup name backfill only updates jobs whose station or
printer still exists.

Tests: the claim that lands just before the delete locks (station, printer),
the claim in flight that the delete waits for, a printer refused on a live
station, an already revoked station, rollback and retry of a failed
transaction, the 23503 mapping, the final step refusing after the review,
review plan errors, and a tenant-scoped aggregate over print jobs.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:20:44 -06:00
james.maes cf650c51aa Merge pull request 'feat(admin): delete print stations and printers; "Print station"; an aggregate is a read' (#39) from feat/delete-stations-printers-20261111 into main
build / maven (push) Successful in 3m37s
maven / build (push) Successful in 3m37s
2026-11-11 21:56:53 +00:00
james.maesandClaude Fable 5.2 7ac849d09f feat(admin): delete print stations and printers; "Print station"; an aggregate is a read
build / maven (push) Successful in 2m38s
maven / build (push) Successful in 2m38s
Delete print stations (printingDeleteDevices) and Delete printers
(printingDeleteSelectedPrinters), on a selection and on the record screen,
behind a new permission, printing_delete, required in addition to
printing_admin. A station is revoked through the existing REVOKE control,
then its queued jobs are canceled, jobs sent without a reported result are
closed as OUTCOME_UNKNOWN, and its control receipts, printers and row are
deleted in one transaction. A station or printer that is connected and
printing is refused by name; the rest of the selection is still deleted.

Jobs are never deleted. printing_job gains device_name, computer_name,
printer_name and queue_name (the names at submission), keeps device_id,
printer_id and printer_row_id as the identifiers the records had, and loses
its foreign key to printing_device. printing_deletion records who deleted
what, when and what went with it (table Deletion history). A computer
enrolled again is a new station; a queue shared again is a new printer
awaiting approval.

Labels: "Print device" becomes "Print station" everywhere an administrator
reads it, from one place (PrintingNames). Table, field and process names,
the HTTP API texts and the agent protocol are unchanged.

PrintingReadPermission refused an aggregate (and an export) as a write, for
everybody. Both are reads and now need exactly the permission a query needs.

Schema step for hosts that own the schema is in CHANGELOG.md.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:57:43 -06:00
james.maes 40089937bf Merge pull request 'fix(jobs): store the print job creation instant as a real date-time (created_time)' (#38) from fix/print-job-created-date-time into main
build / maven (push) Successful in 3m13s
maven / build (push) Successful in 3m13s
2026-11-11 14:09:21 +00:00
james.maesandClaude Fable 5.2 d9f0c357a7 fix(jobs): store the print job creation instant as a real date-time (created_time)
build / maven (push) Successful in 2m14s
maven / build (push) Successful in 2m14s
printing_job.created_at is ISO-8601 text and its QQQ field is a string, so QQQ
could not apply a relative date filter ("created in the last day"), a date sort
or a date bucket to print jobs.

- printing_job gains created_time, a TIMESTAMP without time zone holding UTC
  (the QQQ date-time convention), written by the same INSERT from the same
  instant as created_at.
- The job table exposes it as the read-only DATE_TIME field "Created"; it
  replaces the text field in the default columns and Summary section. The text
  field stays (relabelled "Created at (ISO text)") because the queue order,
  history cursors, admission seeding, the Printing home and the job API read it.
- initialize() adds the column, backfills existing rows from created_at and
  creates printing_job_created_time_idx (tenant_id, created_time, id).
- A host that owns the schema must add the column first (PostgreSQL statements
  in CHANGELOG.md). Without it every submission is rolled back with the
  missing-column error and QQQ cannot read the table; nothing is stored silently.

No wire or contract change (extension 2.4.0, wire v1).

Tests: PrintingJobCreatedTimeTest (5 tests; all fail without the change).
./mvnw -B verify -Pui-demo: 209 tests, 0 failures, 0 errors, 1 skipped
(opt-in workload test), 0 Checkstyle violations.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 07:21:31 -06:00
james.maes bd08d03654 Merge pull request 'build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)' (#35) from deps/align-gitea into main
build / maven (push) Successful in 5m31s
maven / build (push) Successful in 5m31s
2026-11-07 17:18:31 +00:00
james.maesandClaude Opus 5.5 107a566891 build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)
build / maven (push) Successful in 4m41s
maven / build (push) Successful in 4m41s
pom.xml: parent -> 2.2.1
pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.25
pom.xml: qqq-frontend-next: 1.0.0-RC.37 -> 1.0.0-RC.38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:59 +00:00
james.maes 31e36b0b5b Merge pull request 'docs(release): record QQQ Printing 1.1.2 as the current stable library' (#34) from docs/release-1.1.2-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 5m49s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 15s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 14s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 5m49s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 20:47:58 +00:00
james.maesandClaude Fable 5.1 c2f6fc300a docs(release): record QQQ Printing 1.1.2 as the current stable library
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 7m16s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (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: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 11s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (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-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
maven / build (push) Successful in 7m16s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
1.1.2 = the 1.1.1 code on QQQ 4.1.0-RC.22 from the Gitea registry, built and
published by the shared ci-workflows pipeline (push build 6435, tag build 6475).
README, RELEASES, REALTIME and SESSION-STATE point at it with the verified hashes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 14:45:40 -06:00
james.maes f5639a98be Merge pull request 'build: QQQ 4.1.0-RC.22 from the Gitea registry; shared ci-workflows Maven pipeline; 1.1.2' (#33) from ci/gitea-registry-rc22-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 9m36s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 17s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 9m36s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 20:18:16 +00:00
james.maesandClaude Fable 5.1 c31a8f5f33 docs: frontend-next 1.0.0-RC.37 is served from the Gitea registry
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
build / maven (push) Successful in 6m52s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (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: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 12s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (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-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
maven / build (push) Successful in 6m52s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Mirrored byte-for-byte from Maven Central (verified against Central's SHA-1 and read
back from the registry), so the ui-demo profile resolves from Gitea too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 13:32:16 -06:00
james.maesandClaude Fable 5.1 8d5b72302c test: pin the vendored protocol fixtures README to protocol 1.1.0 (5005f3c)
The copied files are byte-identical between 42c0d249 and 5005f3c9; only the
documented pin moves, matching the released protocol the server pairs with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 13:32:16 -06:00
james.maesandClaude Fable 5.1 3c39a6af1b build: QQQ 4.1.0-RC.22 from the Gitea registry; shared ci-workflows Maven pipeline; 1.1.2
Everything moves to the QRun-IO Gitea Maven registry. QQQ 4.1.0-RC.22 exists only
there, and the protected qrun-release channel resolves only Maven Central, so the
repository now builds on the shared KofTwentyTwo/ci-workflows maven workflow (@v1):
every push runs -Pui-demo verify against the registry with the job token; a vX.Y.Z
tag tests once and deploys the JAR/POM under that version. The qrun-release caller
and its trusted-publisher path are retired for this repository; PrintingContractTest
keeps using the vendored protocol fixtures. No printing contract change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 13:32:15 -06:00
james.maes c761eca970 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#31) from ci/ci-workflows into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
build / maven (push) Successful in 5m55s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 10s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 5m55s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 18:02:15 +00:00
james.maes f1ed178355 Merge pull request 'docs(release): record QQQ Printing 1.1.1 as the current stable library' (#32) from docs/release-1.1.1-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 6m12s
qrun-release / postpublish (push) Successful in 30s
Shared Java Maven CI / secrets (push) Successful in 39s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: native / native-result (push) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 12s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 16s
qrun-release / release (push) Successful in 6m45s
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
Shared QRun source version / version (push) Successful in 29s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 16:36:22 +00:00
james.maesandClaude Fable 5.1 f10cc03d9e docs(release): record QQQ Printing 1.1.1 as the current stable library
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 8m7s
qrun-release / postpublish (push) Successful in 40s
Shared Java Maven CI / secrets (push) Successful in 44s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (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: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 34s
qrun-release / release (push) Successful in 8m43s
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-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
Shared QRun source version / version (push) Successful in 40s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
README, RELEASES, REALTIME and SESSION-STATE now point at 1.1.1 (source 2a56181,
main run 5976, reactor artifact 4426, release 1381) with the verified JAR/POM hashes,
and mark 1.1.0 as superseded by the realtime upgrade header-case fix. The release
caller is untouched, so the publisher enrollment hash stays valid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 10:33:25 -06:00
james.maesandClaude Opus 5.5 454410081d ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 19s
build / maven (push) Successful in 5m1s
QRun-IO/ci-policy: ci / head-profile (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: Shared Java Maven CI / secrets (pull_request) Successful in 18s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 4m55s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 5m18s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 8s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (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-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
maven / build (push) Successful in 5m1s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:51:08 +00:00
james.maes 2a561815fc Merge pull request 'fix(realtime): resolve the device on the authorized upgrade (lowercase headers); 1.1.1' (#30) from fix/realtime-header-case-20261106 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 6m56s
qrun-release / postpublish (push) Successful in 43s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
Shared Java Maven CI / secrets (push) Successful in 38s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 4m8s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 7s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 31s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 23s
qrun-release / release (push) Successful in 7m28s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 13s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 4m28s
Shared QRun source version / version (push) Successful in 43s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 15:30:59 +00:00
james.maesandClaude Fable 5.1 e7721bdf90 fix(realtime): resolve the device on the authorized upgrade so lowercase headers connect; 1.1.1
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 4m14s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 13s
qrun-release / postpublish (push) Successful in 27s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: ci / head-profile (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: Shared Java Maven CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m51s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m8s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 8s
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: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 15s
qrun-release / release (push) Successful in 4m32s
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-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
Shared QRun source version / version (push) Successful in 27s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
The WebSocket callbacks read X-QQQ-Device-Id from the socket context, whose
header lookup is case-sensitive, while the HTTP upgrade matches names
case-insensitively. Behind an ingress that lowercases header names the device
resolved to null on connect and channels.put threw before WELCOME (Forge's
adoption test of released 1.1.0; Forge had carried a local patch on 1.0.0).
Resolve the device once after authorization on the upgrade and carry it as a
connection attribute for connect, message and close. The new
lowercaseUpgradeHeadersReachWelcomeAndFrames test fails on the previous code
with the same NullPointerException and passes here. No contract change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:24:44 -06:00
james.maes c197c5acab Merge pull request 'docs(release): record QQQ Printing 1.1.0 and pin protocol fixtures to 1.1.0' (#29) from docs/release-1.1.0-20261105 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 5m34s
qrun-release / postpublish (push) Successful in 31s
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 26s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 4m55s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 11s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 36s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
qrun-release / release (push) Successful in 5m54s
Shared Java Maven CI / gate (push) Successful in 13s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 17s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 5m59s
Shared QRun source version / version (push) Successful in 31s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-06 14:16:35 +00:00
james.maesandClaude Fable 5.1 ba7fdc23d0 docs(realtime): connector 1.2.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 3m45s
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
qrun-release / postpublish (push) Successful in 14s
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m2s
Shared Java Maven CI / secrets (push) Successful in 20s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m43s
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: Shared Java Maven CI / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 8s
qrun-release / release (push) Successful in 3m58s
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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (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
Shared QRun source version / version (push) Successful in 14s
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 / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:55:28 -06:00
james.maesandClaude Fable 5.1 ab51f8da06 docs: the paired Windows connector is 1.2.1 (1.2.0 was never published)
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 3m57s
qrun-release / postpublish (push) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (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: Shared Java Maven CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 4m45s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m57s
Shared Java Maven CI / secrets (push) Successful in 18s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 10s
qrun-release / release (push) Successful in 4m8s
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-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
Shared QRun source version / version (push) Successful in 15s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:54:57 -06:00
james.maesandClaude Fable 5.1 50d643d959 docs(readme): released 1.1.0, install snippets and the 1.2.1 connector pairing
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
Shared Java Maven CI / verify (push) Successful in 3m49s
qrun-release / postpublish (push) Successful in 17s
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 / 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
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Canceled after 27s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Canceled after 34s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Canceled after 33s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 6s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-core-2-head (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 / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 11s
qrun-release / release (push) Successful in 4m3s
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 / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Shared QRun source version / version (push) Successful in 17s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:54:20 -06:00
james.maesandClaude Fable 5.1 102fde779c docs(release): record QQQ Printing 1.1.0 and pin protocol fixtures to 1.1.0
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 5m27s
qrun-release / postpublish (push) Skipped
Shared Java Maven CI / secrets (push) Failing after 0s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (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: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Failing after 13s
qrun-release / release (push) Failing after 5m49s
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-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 / node-9 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 17:09:05 -06:00
james.maes eb2c16785d Merge pull request 'feat(printers): remove printers and devices as archives, purged by retention' (#28) from feat/remove-printers-devices-main-20261105 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 3m35s
qrun-release / postpublish (push) Successful in 18s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: native / native-result (push) Failing after 1s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Failing after 1s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Successful in 11s
qrun-release / release (push) Successful in 3m49s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
Shared QRun source version / version (push) Successful in 17s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-05 22:59:43 +00:00
james.maesandClaude Fable 5.1 e33823bbbd feat(printers): remove printers and devices as archives, purged by retention
Shared Java Maven CI / verify (push) Successful in 4m44s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
Shared Java Maven CI / secrets (push) Failing after 1s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (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: Shared Java Maven CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 4m2s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m11s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
Shared Java Maven CI / gate (push) Failing after 11s
qrun-release / release (push) Failing after 4m59s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (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-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 / node-9 (pull_request) Skipped
Add nullable removed_at to printing_printer and printing_device (read-only
"Removed at" on both tables) through the existing idempotent migration.

Remove printers (printingRemoveSelectedPrinters, record and selection) and
PrintingService.removePrinter set removed_at, clear approval and exposure,
and drop the printer from the operator catalog, submissions, claims, the
device/printer selectors and the PRINTERS frame, which is pushed again. A
removed queue that the device shares again returns exposed but not
approved; approval changes on a removed printer are refused.

Remove device (printingRemoveDevices, confirmation required) revokes access
through the existing REVOKE control, then PrintingService.removeDevice
archives the device and all of its printers; an already revoked device is
archived without a new command. Removed devices leave the device list,
every device selector and all authenticated paths; job history stays.

Retention cleanup hard-deletes removed printers that no job references and
removed devices with no jobs and no printers, bounded by the batch size and
logged. Native inventory authorization still covers removed rows so a
re-shared queue can reactivate.

Tests cover hiding, re-sharing, device removal with history, purge bounds
and process metadata. Docs: README, UI walkthrough, storage and retention,
realtime, changelog (1.1.0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 16:31:33 -06:00
james.maes d6edfced8a Merge pull request 'feat(realtime): printer approval over the socket (extension 2.4.0, product 1.1.0)' (#25) from feat/printer-approval-20261105 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / verify (push) Failing after 3s
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
qrun-release / postpublish (push) Skipped
Shared Java Maven CI / secrets (push) Failing after 2s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 2s
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 4m23s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Successful in 8s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Failing after 2s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
Shared Java Maven CI / gate (push) Failing after 14s
qrun-release / release (push) Failing after 2m16s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 0s
QRun-IO/ci-policy: ci / java-maven-7 (push) Failing after 4m26s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-05 22:22:18 +00:00
james.maesandClaude Fable 5.1 3fa78decc8 feat(realtime): printer approval over the socket (extension 2.4.0, product 1.1.0)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
Shared Java Maven CI / verify (push) Successful in 3m31s
qrun-release / postpublish (push) Successful in 14s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (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
Shared Java Maven CI / secrets (push) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m6s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m16s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
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-head (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 9s
qrun-release / release (push) Successful in 3m41s
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-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
Shared QRun source version / version (push) Successful in 14s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Add the printer-approval-v1 capability and the server-to-client PRINTERS frame
so a connector can show per-printer approval for the server it is connected to.

- HELLO accepts up to five capabilities including printer-approval-v1; the HTTP
  control poll accepts up to four and stores it in control_capabilities like the
  others; the ControlPollRequest schema enumeration is extended.
- After an accepted INVENTORY's ACK, a connection that advertised the capability
  receives PRINTERS: { "printers": [ { "printerId", "enabled" } ] } covering every
  printer in the device's latest accepted inventory (empty inventory: []).
- Every approval change (admin route, native approve/disable/set-approval
  processes, PrintingService.enablePrinter) already publishes the commit wakeup;
  the connection's single serialized writer now compares the current approval
  state with the last PRINTERS frame it sent and resends on change only.
  Connections without the capability never receive PRINTERS.
- PrintingService.printerApprovals(deviceId) exposes the list to host code.
- Advertise native extension 2.4.0; regenerate the OpenAPI fragment; bump the
  product to 1.1.0 (pairs with protocol 1.1.0 and Windows connector 1.2.0; wire
  payload v1 and subprotocol unchanged). CHANGELOG, README, REALTIME/OPERATIONS
  docs and a dated in-progress session-state note.
- Tests: five-capability HELLO, ACK-then-PRINTERS ordering with new printers
  disabled, approval via the admin route and the service pushing fresh frames,
  no PRINTERS on an unrelated job wakeup, empty inventory, a four-capability
  connection never receiving PRINTERS, and control-poll acceptance/bounds.

Verified: .\mvnw.cmd -Pui-demo verify -> 195 tests, 0 failures, 0 errors,
1 skipped (opt-in workload), 0 Checkstyle violations, BUILD SUCCESS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 15:45:16 -06:00
james.maes 0ab07fc87c Merge pull request 'docs: close out printing 1.0 setup and release guidance' (#24) from docs/printing-release-closeout-20261104 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 4m19s
qrun-release / postpublish (push) Successful in 15s
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 16s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 3m33s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 16s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
Shared Java Maven CI / gate (push) Successful in 10s
qrun-release / release (push) Successful in 4m30s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 12s
QRun-IO/ci-policy: ci / java-maven-7 (push) Failing after 4m17s
Shared QRun source version / version (push) Successful in 15s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-04 15:48:58 +00:00
james.maesandCodex 1f6342e871 docs: close out printing 1.0 release documentation
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 3m49s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 10s
qrun-release / postpublish (push) Successful in 16s
QRun-IO/ci-policy: ci / head-profile (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
Shared Java Maven CI / secrets (push) Successful in 13s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m41s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m53s
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
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 3s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Shared Java Maven CI / gate (push) Successful in 10s
qrun-release / release (push) Successful in 4m1s
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-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
Shared QRun source version / version (push) Successful in 16s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Update setup, operating and release guidance for the published Gitea artifacts, native API and real-time transport. Preserve historical evidence and scope the older optional Authentik fixture accurately.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-04 09:39:13 -06:00
james.maes 2906c2bd50 Merge pull request 'Release printing 1.0.0 with QQQ native sign-in support' (#23) from release/printing-1.0.0-20261103 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 4m15s
qrun-release / postpublish (push) Successful in 16s
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
Shared Java Maven CI / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 3s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 5m26s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
Shared Java Maven CI / gate (push) Successful in 11s
qrun-release / release (push) Successful in 4m29s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 5m37s
Shared QRun source version / version (push) Successful in 16s
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
2026-11-03 22:45:08 +00:00
james.maesandCodex fbec343cb2 docs: finalize printing 1.0 release wording
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI / verify (push) Failing after 31s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
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: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 4s
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 16s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / head-profile (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: Shared Java Maven CI / verify (pull_request) Successful in 2m45s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 3m18s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 22s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 16s
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-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
Shared Java Maven CI / gate (push) Failing after 12s
qrun-release / release (push) Failing after 1m31s
QRun-IO/ci-policy: ci / gradle-1 (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 / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 21s
QRun-IO/ci-policy: ci / java-maven-7 (push) Failing after 1m30s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) 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-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 / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 15:06:40 -06:00
james.maesandCodex 48e6cdb9df release: adopt published QQQ RC.21 for printing 1.0
Shared Java Maven CI / verify (push) Failing after 39s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 4s
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 20s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 29s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 19s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
Shared Java Maven CI / gate (push) Failing after 10s
qrun-release / release (push) Failing after 53s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 10s
QRun-IO/ci-policy: ci / java-maven-7 (push) Failing after 51s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 15:04:49 -06:00
james.maesandClaude Sonnet 5.5 9ccd9d9b43 release: prepare printing 1.0.0 dependency adoption
Document the final product scope and remaining native-login core publication dependency. Preserve the current core pin until the supported artifact is published.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:53:23 -06:00
forge-recovery a583f91abe Merge pull request 'ci: add the QRun release caller' (#22) from ci-qrun-release-caller-main-v1 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / verify (push) Successful in 3m20s
qrun-release / postpublish (push) Successful in 3m15s
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 16s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m56s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
Shared Java Maven CI / gate (push) Successful in 9s
qrun-release / release (push) Successful in 3m31s
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (push) Successful in 3m9s
Shared QRun source version / version (push) Successful in 3m15s
2026-11-02 16:45:35 +00:00
d5c32f854e ci: add the isolated postpublish job to the QRun release caller
qrun-release / postpublish (push) Successful in 3m17s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
Shared Java Maven CI / verify (push) Successful in 6m45s
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 10m42s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 19s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 42s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m34s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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: native / native-result (pull_request) Successful in 6s
qrun-release / release (push) Successful in 10m59s
Shared Java Maven CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / gradle-1 (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 / java-docker-3-head (pull_request) Skipped
Shared QRun source version / version (push) Successful in 3m17s
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-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 / node-9 (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:51:46 +00:00
edc1385bd5 ci: move the QRun release caller to the thin v2 contract
Shared Java Maven CI / verify (push) Successful in 6m5s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 5m36s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 26s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (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 / gradle-1 (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 36m28s
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 35m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:19:40 +00:00
forge-recoveryandCodex 29d1ae5f38 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 1m50s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:13:32 +00:00
cf57dfb521 release: prepare qbit-printing 0.4.0-rc.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Successful in 3m59s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m48s
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 7s
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: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 3m43s
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Successful in 4m4s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 9s
Use an immutable prerelease version for the existing JAR/POM release workflow; leave the parent, QQQ SDK, dependencies and printing code unchanged.

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

Co-Authored-By: Codex <noreply@openai.com>
2026-11-01 21:37:35 -06:00
fc99caf85e test: make pinned printing contract fixtures self-contained
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 4m6s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 3m50s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 3m36s
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 (push) Successful in 15s
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Successful in 3m47s
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 9s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
Keep the exact existing protocol fixture bytes and the explicit cross-repository override. Default Maven verification no longer depends on a sibling checkout.

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

Co-Authored-By: Codex <noreply@openai.com>
2026-11-01 20:52:57 -06:00
aaa4a9ff03 style: normalize development project copyright headers
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
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 15m52s
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 3m54s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 4s
QRun-IO/ci-policy: ci / gradle-0 (push) 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 (push) Failing after 3m42s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Failing after 16m22s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-01 18:17:10 -06:00
james.maesandClaude Sonnet 5.5 38fd06229e fix: complete printing cohort verification and pin its contract
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 11s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Failing after 15m13s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 5s
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 4m10s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 33s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 4m31s
QRun-IO/ci-policy: ci / gradle-0 (push) 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 (push) Successful in 18s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Failing after 25m30s
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 9s
Keep the compiler-clean label test helper and record the passed 193-case verification and matching protocol fixture pin without changing runtime behavior.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 17:30:18 -06:00
james.maesandClaude Sonnet 5.5 111cbede8f feat: integrate compact label tests with strict server contracts
Preserve the current permission, retained-source and queue implementation while adding job-specific paper and compact four by one and four by two labels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 17:01:23 -06:00
james.maesandClaude Sonnet 5.5 43253f6676 chore: retain protected printing script quality ancestry
Integrate the already approved script main and preserve both sets of completed task notes; no application code changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 16:53:08 -06:00
james.maesandClaude Sonnet 5.5 e4639e3cca chore: integrate printing standards and strict Java compiler checks
Preserve current rendering, operator and retained-source behavior while integrating script quality, meaningful headers and strict Error Prone/NullAway contracts. Production and test compilation and the 189-case verification gate pass with zero failures and one intentional workload skip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 16:51:18 -06:00
james.maesandClaude Sonnet 5.5 9811979d6c feat: add branded tests for short Zebra label stock
Generate compact four by one and four by two PDF and ZPL labels, and carry explicit PDF paper dimensions per job without altering queue defaults.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 16:34:51 -06:00
james.maes 088692e3ae Merge pull request #33 from QRun-IO/chore/script-quality-gates-20261101
QRun-IO/ci-policy: ci / ci-result (push) Failing after 4s
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 3m26s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 31s
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Failing after 25m52s
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 10s
chore: enforce script lint and type quality gates
2026-11-01 15:57:31 -06:00
james.maes f2505160c6 chore: preserve current main in script quality branch
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 15:17:23 -06:00
james.maesandClaude Sonnet 5.5 13d6ee1a23 fix(tooling): use official Pyright and patched minimatch
Keep strict Python checking at 1.1.414 using Microsoft's npm package,
removing the unnecessary Python wrapper and its bootstrap dependencies.
Pin minimatch 11.0.1 for the checked JavaScript tools and preserve actual
negative quality probes and all license/security checks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 15:08:27 -06:00
james.maes 64c9ebf0fa Merge pull request #30 from QRun-IO/feat/echo-printing-catalog-20261101
feat: authorize printer media and retained acceptance
2026-11-01 14:48:26 -06:00
f80498513e chore(scripts): enforce locked lint and type quality gates
Add strict Python and checked JavaScript tooling, explicit source-scope
checks and real rejection probes to CI. Preserve existing runtime
behavior and document the limited bundle and test portability changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 14:47:49 -06:00
james.maesandClaude Sonnet 5.5 65e32f5ce9 fix(printing): reconcile 2.3 and await realtime shutdown
Preserve the rendering changes from main while assigning operator,
media and retained-source support to printing 2.3. Wait for channel
workers before stopping the host so they cannot recreate a closed store.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 14:42:57 -06:00
james.maesandClaude Fable 5.1 5f022541b1 chore(api): printing extension 2.2.0 (#32)
Declares extension 2.2.0 for the additive features merged in #31 (printingRenderToPdf, header/footer values, content sections): PrintingUiApi, the regenerated OpenAPI document, the contract test, CHANGELOG and README. Clients detect features by process presence, not by version.

Signed-off-by: James Maes <jmaes@greatergoods.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:39:11 -06:00
james.maesandClaude Fable 5.1 a514a70953 feat(printing): render to PDF for the caller; app header and footer on every page (#31)
printingRenderToPdf renders a record sheet, table list, text page or content sections and delivers the PDF through core's standard process download with the caller's permissions, no printer and no queued job. Every rendered document carries an app header (app name, breadcrumb, server, user, time) and footer (client and server, filter/sort line, Page n of m) from values the client sends, with server defaults. Extension 2.2 (additive).

Signed-off-by: James Maes <jmaes@greatergoods.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:32:37 -06:00
james.maesandCodex GPT-6 4eee28e9db ci: pin the reviewed media protocol contract
Use protected protocol source a26c18c for compatible consumer verification. Fail local Windows verification early when required shared fixtures are missing instead of building a test assembly with incomplete inputs.

Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 13:10:23 -06:00
james.maes 88f5fbb4b5 Merge branch 'main' of https://github.com/QRun-IO/qbit-printing into feat/echo-printing-catalog-20261101
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 13:05:09 -06:00
james.maesandCodex GPT-6 08ee0efa3d feat: authorize operator printing with exact retained acceptance
Add current-user printer media catalog, stable request recovery and atomic retained-source acceptance. Recheck native authority through admission and commit, bind history to the accepted printer record, and gate selected media on the current client capability.

Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 13:04:03 -06:00
james.maesandCodex 1a0964ef23 chore: enforce owned source copyright notices (#25)
* chore(standards): preserve owned-source copyright notices

Add accurate repository copyright and license comments to 50 owned source/build/configuration files. Preserve existing holders, upstream wrappers, legal payloads and schema/data exemptions. Exact inverse-byte reconstruction and applicable parser/token/AST checks prove comment-only changes; existing relevant regressions pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

* chore: enforce owned source copyright notices

Check real leading comments against explicit repository provenance and retain existing native style gates.

Co-Authored-By: Codex GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-11-01 12:41:00 -06:00
james.maesandCodex 02b922aa11 fix(dev): accept compatible printing discovery in native provider tests (#23)
security / security / sca (push) Canceled after 0s
QRun-IO/ci-policy: ci / node-3 (push) Skipped
Build and prerelease / release-security (push) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
Build and prerelease / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 6s
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
security / security / workflows (push) Canceled after 0s
codeql / analyze (java-kotlin) (push) Canceled after 0s
Build and prerelease / release (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Failing after 50s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
development / development-scripts (windows-latest) (push) Canceled after 0s
development / development-docker (push) Canceled after 0s
codeql / analyze (actions) (push) Canceled after 0s
codeql / codeql (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 13s
QRun-IO/ci-policy: ci / node-4 (push) Skipped
development / development-scripts (ubuntu-24.04) (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-2 (push) Failing after 17m11s
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Failing after 12s
Build and prerelease / release-codeql (push) Skipped
security / security / secrets (push) Canceled after 0s
Reuse production Core strict discovery and the shared printing-major-2 gate while retaining the exact experimental API 1.9/native-session requirement. Add build-time fake-HTTP boundary cases without changing PKCE, TLS, provider or permission handling.

The old native gate fails compatible 2.1.0; the fix passes 12 native and nine default discovery cases with zero compiler warnings/errors. One fresh current-source Docker provider run passes all eight checks with verified owned cleanup. Preserve unreleased-upstream and installed-browser limitations; no live runtime or physical printing changes.

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-11-01 12:04:51 -06:00
james.maesandCodex bfba557b66 docs: record published Docker qualification (#22)
Record protected PR21 publication and passing hosted Docker, script and Java results. Keep the experimental upstream candidate distinct from released-runtime adoption and the unchanged live installation. Both Markdown files and diff checks pass; no runtime changes. Prepared and reviewed with Codex (GPT-6).

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-11-01 10:53:45 -06:00
james.maesandCodex 3592decba8 feat(dev): add isolated Docker printing and provider qualification (#21)
* feat(dev): add isolated Docker printing and provider qualification

Provide owned local QQQ, MinIO and optional Authentik development fixtures with pinned inputs, protected synthetic state and ownership-checked cleanup. Exercise real Core/Java WebSocket jobs, S3 retention/isolation and opt-in native PKCE/provider renewal and revocation. Add cross-platform script gates and hosted Docker smoke CI.

Include the opt-in 10000-job pooled-SQL/reopen regression and scoped evidence. The experimental API1.9 source remains explicitly unreleased and separate from the released runtime dependency set.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

* fix(dev): report early workload failures before server waiting

Preserve bounded client diagnostics privately and emit only allowlisted stage and exception classifications. Early failed clients no longer hide behind the server completion timeout; ownership-checked cleanup remains in finally.

Five actual-entrypoint regression cases pass; all33script tests and parser/analyzer checks pass. This exposes the still-unknown hosted client failure without changing production behavior.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

* fix(dev): accept compatible realtime printing API minor versions

Use production Core discovery's strict stable SemVer validation and the realtime major2 compatibility gate instead of requiring exactly2.0.0. Protected main now advertises compatible2.1.0; the old fixture rejected it before login.

Pinned-Core build reproduced2.1.0 rejection before the fix and passes all9positive/negative HTTP discovery cases after it. Docker builds run these checks. No production client, server, dependency or runtime change.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-11-01 10:12:08 -06:00
james.maes 2b2ad3346a feat(printing): print any table's records, table lists and text pages (#20)
PrintingRecordProcesses.registerAll adds Print to every readable table (opt-out per table); record sheets are built from table metadata; printingPrintTableList prints a grid's columns, widths, filter and sort for all rows (1,000 at a time up to 10,000, truncation reported) or the current page with repeated headers and page footers; printingPrintText prints monospace text pages with a shrinking font. Version 0.4.0-SNAPSHOT, extension 2.1.0 (additive). The new processes' inputs are editable so user values are accepted over the UI API.

Refs QRun-IO/qqq#1366
Signed-off-by: James Maes <jmaes@greatergoods.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:00:08 -06:00
james.maes ea3ba0d698 feat(printing): print records of host tables as record sheets (#18)
PrintingRecordProcesses.register(instance, table) adds a Print process per host table: each selected record is rendered as a one-page record sheet and queued as its own PDF job, job ids returned in printingJobIds. Also fixes selection actions whose first step is a form losing the record selection over the UI API (the prepare steps now keep it), which affected the existing test-page action.

Refs QRun-IO/qqq#1366
Signed-off-by: James Maes <jmaes@greatergoods.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:14:48 -06:00
205 changed files with 20511 additions and 1133 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
.git
**/bin
**/obj
target
.local
.venv
+11
View File
@@ -0,0 +1,11 @@
# Shared QRun Maven CI/CD (KofTwentyTwo/ci-workflows): every push verifies; vX.Y.Z tags publish the
# tested JAR/POM to the QRun-IO Gitea Maven registry. QQQ resolves from that registry with the job token.
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
with:
maven-args: -Pui-demo
secrets: inherit
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: Bug report
description: Report a reproducible printing QBit issue.
title: 'bug: '
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
blank_issues_enabled: true
contact_links:
- name: Report a security vulnerability privately
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
version: 2
updates:
- package-ecosystem: maven
+15
View File
@@ -0,0 +1,15 @@
{
"typeCheckingMode": "strict",
"pythonVersion": "3.13",
"include": [
"scripts/*.py"
],
"reportMissingTypeStubs": "error",
"reportUnnecessaryTypeIgnoreComment": "error",
"reportImplicitOverride": "error",
"exclude": [
"**/__pycache__"
],
"venvPath": "..",
"venv": ".venv"
}
+98 -43
View File
@@ -1,53 +1,108 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
import json
import math
import os
from pathlib import Path
from typing import TypedDict
RuleProperties = TypedDict("RuleProperties", {"security-severity": str | float | None}, total=False)
def check(directory, language, threshold):
if not math.isfinite(threshold) or not 0 <= threshold <= 10:
raise ValueError("Invalid security severity threshold")
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
files = sorted(Path(directory).glob("*.sarif"))
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
raise ValueError("Missing expected CodeQL SARIF report for " + language)
blocking = []
for path in files:
report = json.loads(path.read_text(encoding="utf-8"))
runs = report.get("runs")
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
raise ValueError("Invalid or empty SARIF runs: " + path.name)
for run in runs:
driver = run.get("tool", {}).get("driver", {})
results = run.get("results")
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
rules = {}
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
for rule in component.get("rules", []):
rules[rule["id"]] = rule
if not rules:
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
for invocation in run.get("invocations", []):
if invocation.get("executionSuccessful") is False:
raise ValueError("CodeQL invocation failed: " + path.name)
for result in results:
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
if rule_id not in rules:
raise ValueError("Result references missing rule metadata: " + path.name)
if result.get("suppressions"):
continue
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
if not math.isfinite(severity) or not 0 <= severity <= 10:
raise ValueError("Invalid rule severity: " + rule_id)
if severity >= threshold:
blocking.append((severity, rule_id))
for severity, rule_id in sorted(blocking, reverse=True):
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
return bool(blocking)
class Rule(TypedDict):
"""Keep the rule identity and optional severity metadata used by the gate."""
id: str
properties: RuleProperties
class Component(TypedDict, total=False):
"""Describe CodeQL driver or extension rule metadata."""
name: str
rules: list[Rule]
class Tool(TypedDict, total=False):
"""Group the driver and optional extension rule catalogs."""
driver: Component
extensions: list[Component]
class Result(TypedDict, total=False):
"""Describe only the result fields consumed by the severity gate."""
ruleId: str
rule: dict[str, str]
suppressions: list[object]
class Run(TypedDict, total=False):
"""Retain invocation success and findings for one analyzed language."""
tool: Tool
results: list[Result]
invocations: list[dict[str, bool]]
class Report(TypedDict, total=False):
"""Represent SARIF fields while retaining the explicit runtime validation."""
version: str
runs: list[Run]
MAX_SEVERITY = 10
def check(directory: str | Path, language: str, threshold: float) -> bool: # noqa: PLR0912 -- Keep the ordered evidence checks together; none may be skipped.
"""Validate complete CodeQL evidence and report findings at the threshold."""
if not math.isfinite(threshold) or not 0 <= threshold <= MAX_SEVERITY:
raise ValueError("Invalid security severity threshold")
canonical = {"java-kotlin": "java", "javascript-typescript": "javascript", "c-cpp": "cpp"}.get(language, language)
files = sorted(Path(directory).glob("*.sarif"))
if not canonical or not any(path.name == canonical + ".sarif" for path in files):
raise ValueError("Missing expected CodeQL SARIF report for " + language)
blocking: list[tuple[float, str]] = []
for path in files:
report: Report = json.loads(path.read_text(encoding="utf-8"))
runs = report.get("runs")
if report.get("version") != "2.1.0" or not isinstance(runs, list) or not runs:
raise ValueError("Invalid or empty SARIF runs: " + path.name)
for run in runs:
driver = run.get("tool", {}).get("driver", {})
results = run.get("results")
if driver.get("name") != "CodeQL" or not isinstance(driver.get("rules"), list) or not isinstance(results, list):
raise ValueError("Missing CodeQL tool/rules/results evidence: " + path.name)
rules: dict[str, Rule] = {}
for component in [driver, *run.get("tool", {}).get("extensions", [])]:
for rule in component.get("rules", []):
rules[rule["id"]] = rule
if not rules:
raise ValueError("CodeQL report has no analyzed rule metadata: " + path.name)
for invocation in run.get("invocations", []):
if invocation.get("executionSuccessful") is False:
raise ValueError("CodeQL invocation failed: " + path.name)
for result in results:
rule_id = result.get("ruleId") or result.get("rule", {}).get("id")
if rule_id not in rules:
raise ValueError("Result references missing rule metadata: " + path.name)
if result.get("suppressions"):
continue
severity = float(rules[rule_id].get("properties", {}).get("security-severity", 0) or 0)
if not math.isfinite(severity) or not 0 <= severity <= MAX_SEVERITY:
raise ValueError("Invalid rule severity: " + rule_id)
if severity >= threshold:
blocking.append((severity, rule_id))
for severity, rule_id in sorted(blocking, reverse=True):
print(f"::error title=CodeQL security finding::{rule_id} ({severity})")
print(f"Validated {len(files)} SARIF report(s); {len(blocking)} blocking result(s)")
return bool(blocking)
if __name__ == "__main__":
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
raise SystemExit(1 if check(os.environ["SARIF_DIR"], os.environ["EXPECTED_LANGUAGE"], float(os.environ["THRESHOLD"])) else 0)
+120
View File
@@ -0,0 +1,120 @@
# Copyright (C) 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Exercise the repository's actual compiler configuration on isolated probes."""
import argparse
import copy
import json
import logging
import os
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
NS = "{http://maven.apache.org/POM/4.0.0}"
LOG = logging.getLogger(__name__)
PROBES: dict[str, tuple[str, str | None]] = {
"positive": ('public String value() { return "present"; }', None),
"nullaway": ("public String value() { return null; }", "[NullAway]"),
"errorprone": ("public void value() { new IllegalArgumentException(); }", "[DeadException]"),
"warnings": ('public long value() { return java.util.Date.parse("2000-01-01"); }', "warnings found and -Werror specified"),
}
@dataclass(frozen=True)
class GuardContext:
"""Everything shared by all probes: repository root, evidence folder, probe POM and Maven command."""
root: Path
output: Path
pom: ET.ElementTree
command: list[str]
@dataclass(frozen=True)
class Probe:
"""One isolated compilation: source root, name, the member source and the diagnostic it must produce (None means it must compile)."""
phase: str
name: str
source: str
expected: str | None
def probe_pom(source: ET.Element) -> ET.ElementTree:
"""Copy compiler configuration, including executions, without changing its flags."""
compiler = source.find(f"{NS}build/{NS}plugins/{NS}plugin[{NS}artifactId='maven-compiler-plugin']")
if compiler is None:
raise ValueError("Repository must declare its compiler plugin")
project = ET.Element(f"{NS}project")
for name, value in (("modelVersion", "4.0.0"), ("groupId", "com.kingsrook.qbits"), ("artifactId", "compiler-configuration-probe"), ("version", "1")):
ET.SubElement(project, f"{NS}{name}").text = value
properties = source.find(f"{NS}properties")
if properties is not None:
project.append(copy.deepcopy(properties))
plugins = ET.SubElement(ET.SubElement(project, f"{NS}build"), f"{NS}plugins")
plugins.append(copy.deepcopy(compiler))
return ET.ElementTree(project)
def run_probe(context: GuardContext, probe: Probe) -> dict[str, str | bool]:
"""Require the intended diagnostic, not merely an unrelated Maven failure."""
folder = context.output / f"{probe.phase}-{probe.name}"
package = folder / "src" / probe.phase / "java" / "com" / "kingsrook" / "qbits" / "printing"
package.mkdir(parents=True)
(package / "Probe.java").write_text(f"// Generated compiler guard fixture; not application source.\npackage com.kingsrook.qbits.printing;\npublic final class Probe {{ {probe.source} }}\n", encoding="utf-8")
context.pom.write(folder / "pom.xml", encoding="utf-8", xml_declaration=True)
result = subprocess.run( # noqa: S603 -- argument list (no shell) of the repository Maven wrapper or the operator-supplied executable plus fixed Maven goals
[*context.command, "-f", str(folder / "pom.xml"), "test-compile"],
cwd=context.root,
capture_output=True,
text=True,
timeout=180,
check=False,
)
log = result.stdout + result.stderr
(folder / "compiler.log").write_text(log, encoding="utf-8")
passed = result.returncode == 0 if probe.expected is None else result.returncode != 0 and probe.expected in log
if probe.name == "warnings":
passed = passed and "[deprecation]" in log
if not passed:
raise RuntimeError(f"Compiler guard failed: {probe.phase}/{probe.name}; inspect {folder / 'compiler.log'}")
return {"sourceRoot": probe.phase, "probe": probe.name, "expectedRejection": probe.expected is not None, "passed": True}
def main() -> None:
"""Run positive and negative main/test probes without installing any artifact."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--maven", help="Optional exact Maven executable; otherwise use the repository wrapper")
parser.add_argument("--settings", type=Path)
parser.add_argument("--local-repository", type=Path)
parser.add_argument("--offline", action="store_true")
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
command = [args.maven] if args.maven else ([str(root / "mvnw.cmd")] if os.name == "nt" else ["bash", str(root / "mvnw")])
command += ["-B", "-ntp"]
if args.offline:
command.append("-o")
if args.settings:
command += ["-s", str(args.settings.resolve())]
if args.local_repository:
command.append(f"-Dmaven.repo.local={args.local_repository.resolve()}")
output_root = root / "target" / "compiler-guard"
output_root.mkdir(parents=True, exist_ok=True)
output = Path(tempfile.mkdtemp(prefix="run-", dir=output_root))
ET.register_namespace("", NS[1:-1])
pom = probe_pom(ET.parse(root / "pom.xml").getroot()) # noqa: S314 -- parses this repository's own checked-in pom.xml, never external input
context = GuardContext(root, output, pom, command)
results: list[dict[str, str | bool]] = []
for phase in ("main", "test"):
for name, (source, expected) in PROBES.items():
results.append(run_probe(context, Probe(phase, name, source, expected)))
(output / "results.json").write_text(json.dumps(results, indent=3) + "\n", encoding="utf-8")
LOG.info("Compiler guard passed", extra={"probeCount": len(results), "evidenceDirectory": str(output)})
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()
+149 -101
View File
@@ -1,125 +1,173 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Fail closed unless scanners cover Maven's selected UI-host runtime graph."""
import argparse
from collections import Counter
import json
from pathlib import Path
import re
from collections import Counter
from pathlib import Path
from typing import TypedDict, cast
from urllib.parse import unquote, urlsplit
class Bom(TypedDict, total=False):
"""Describe the selected CycloneDX coordinate fields."""
bomFormat: str
components: list[dict[str, str]]
metadata: dict[str, dict[str, str]]
class TrivyReport(TypedDict, total=False):
"""Describe package coordinates, leaving runtime shape checks intact."""
Results: list[dict[str, list[dict[str, str]]]]
class OsvReport(TypedDict, total=False):
"""Keep result bodies untrusted until their explicit validation."""
results: list[object]
class OsvResult(TypedDict, total=False):
"""Describe source identity and packages after checking the object shape."""
source: dict[str, str]
packages: list[object]
class OsvEntry(TypedDict, total=False):
"""Describe the coordinate and optional vulnerability array being checked."""
package: dict[str, str]
vulnerabilities: object
REQUIRED = {
"com.kingsrook.qqq:qqq-frontend-next",
"com.kingsrook.qqq:qqq-backend-core",
"com.kingsrook.qqq:qqq-backend-module-filesystem",
"com.google.guava:guava",
"com.h2database:h2",
"com.kingsrook.qqq:qqq-frontend-next",
"com.kingsrook.qqq:qqq-backend-core",
"com.kingsrook.qqq:qqq-backend-module-filesystem",
"com.google.guava:guava",
"com.h2database:h2",
}
REVIEWED_H2_VERSION = "2.4.240"
OMITTED = object()
def resolved(text):
packages = set()
for line in text.splitlines():
line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip().split(" -- ", 1)[0].strip()
if not line or line == "The following files have been resolved:":
continue
parts = line.split(":")
if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"):
raise ValueError(f"Unexpected Maven runtime coordinate: {line}")
if not all(parts) or parts[2] != "jar":
raise ValueError(f"Invalid runtime JAR coordinate: {line}")
name = parts[0] + ":" + parts[1]
if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")):
raise ValueError("Test library in runtime inventory")
packages.add((name, parts[-2]))
if not REQUIRED.issubset({name for name, _ in packages}):
raise ValueError("Maven inventory is empty or lacks required UI-host dependencies")
# The pinned dependency-review license exception matcher ignores versions.
# Keep any H2 metadata exception limited to the reviewed, unmodified runtime.
if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}:
raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION)
return packages
def resolved(text: str) -> set[tuple[str, str]]:
"""Read Maven's selected runtime JARs and enforce the reviewed H2 pin."""
packages: set[tuple[str, str]] = set()
for raw_line in text.splitlines():
line = re.sub(r"\x1b\[[0-9;]*m", "", raw_line).strip().split(" -- ", 1)[0].strip()
if not line or line == "The following files have been resolved:":
continue
parts = line.split(":")
if len(parts) not in (5, 6) or parts[-1] not in ("compile", "runtime"):
raise ValueError(f"Unexpected Maven runtime coordinate: {line}")
if not all(parts) or parts[2] != "jar":
raise ValueError(f"Invalid runtime JAR coordinate: {line}")
name = parts[0] + ":" + parts[1]
if parts[0].startswith(("org.junit", "org.opentest4j", "org.apiguardian")):
raise ValueError("Test library in runtime inventory")
packages.add((name, parts[-2]))
if not REQUIRED.issubset({name for name, _ in packages}):
raise ValueError("Maven inventory is empty or lacks required UI-host dependencies")
# The pinned dependency-review license exception matcher ignores versions.
# Keep any H2 metadata exception limited to the reviewed, unmodified runtime.
if {version for name, version in packages if name == "com.h2database:h2"} != {REVIEWED_H2_VERSION}:
raise ValueError("H2 runtime must match reviewed version " + REVIEWED_H2_VERSION)
return packages
def components(report):
if not isinstance(report, dict) or report.get("bomFormat") != "CycloneDX":
raise ValueError("Expected CycloneDX inventory")
packages = set()
for component in report.get("components", []):
purl = urlsplit(component.get("purl", ""))
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
raise ValueError("Missing Maven package URL")
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
group, artifact = coordinate.split("/", 1)
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
if not packages:
raise ValueError("Empty CycloneDX inventory")
return packages
def components(report: object) -> set[tuple[str, str]]:
"""Extract exact Maven coordinates from the scanner's CycloneDX inventory."""
typed_report = cast(Bom, report)
if not isinstance(report, dict) or typed_report.get("bomFormat") != "CycloneDX":
raise ValueError("Expected CycloneDX inventory")
packages: set[tuple[str, str]] = set()
for component in typed_report.get("components", []):
purl = urlsplit(component.get("purl", ""))
if purl.scheme != "pkg" or not purl.path.startswith("maven/"):
raise ValueError("Missing Maven package URL")
coordinate, version = purl.path.removeprefix("maven/").rsplit("@", 1)
group, artifact = coordinate.split("/", 1)
packages.add((unquote(group) + ":" + unquote(artifact), unquote(version)))
if not packages:
raise ValueError("Empty CycloneDX inventory")
return packages
def check(expected, bom, trivy=OMITTED):
actual = components(bom)
if actual != expected:
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
if trivy is not OMITTED:
if not isinstance(trivy, dict) or not isinstance(trivy.get("Results"), list):
raise ValueError("Expected Trivy report object with Results array")
scanned = {(p["Name"], p["Version"]) for r in trivy.get("Results", []) for p in r.get("Packages", [])}
# Trivy also reports the root project represented by SBOM metadata.
root = bom.get("metadata", {}).get("component", {})
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
if not expected.issubset(scanned) or not scanned.issubset(allowed):
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
return len(expected)
def check(expected: set[tuple[str, str]], bom: object, trivy: object = OMITTED) -> int:
"""Require exact SBOM coverage and, when supplied, selected Trivy coverage."""
actual = components(bom)
if actual != expected:
raise ValueError(f"Maven/SBOM mismatch: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}")
if trivy is not OMITTED:
typed_trivy = cast(TrivyReport, trivy)
if not isinstance(trivy, dict) or not isinstance(typed_trivy.get("Results"), list):
raise ValueError("Expected Trivy report object with Results array")
scanned = {(p["Name"], p["Version"]) for r in typed_trivy.get("Results", []) for p in r.get("Packages", [])}
# Trivy also reports the root project represented by SBOM metadata.
root = cast(Bom, bom).get("metadata", {}).get("component", {})
allowed = expected | {(root.get("group", "") + ":" + root.get("name", ""), root.get("version", ""))}
if not expected.issubset(scanned) or not scanned.issubset(allowed):
raise ValueError(f"Maven/Trivy mismatch: missing={sorted(expected - scanned)}, extra={sorted(scanned - allowed)}")
return len(expected)
def check_osv(expected, report):
if not isinstance(report, dict) or not isinstance(report.get("results"), list):
raise ValueError("Expected OSV report object with results array")
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
# https://github.com/google/osv-scanner/issues/3099
# Fail on ambiguous display coordinates instead of claiming full identity proof.
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
if any(count != 1 for count in projected.values()):
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
scanned = Counter()
for result in report["results"]:
if not isinstance(result, dict) or not isinstance(result.get("source"), dict):
raise ValueError("Missing OSV source")
if result["source"].get("type") != "sbom" or not isinstance(result["source"].get("path"), str):
raise ValueError("Expected OSV SBOM source")
if not isinstance(result.get("packages"), list):
raise ValueError("Missing OSV package inventory; use --all-packages")
for entry in result["packages"]:
if not isinstance(entry, dict) or not isinstance(entry.get("package"), dict):
raise ValueError("Malformed OSV package")
package = entry["package"]
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
raise ValueError("Invalid OSV Maven package coordinate")
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
raise ValueError("Malformed OSV vulnerabilities")
scanned[(package["name"], package["version"])] += 1
if scanned != projected:
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
return len(expected)
def check_osv(expected: set[tuple[str, str]], report: object) -> int:
"""Reject missing, ambiguous, malformed or partial OSV package evidence."""
typed_report = cast(OsvReport, report)
if not isinstance(report, dict) or not isinstance(typed_report.get("results"), list):
raise ValueError("Expected OSV report object with results array")
# OSV 2.6.0 queries CycloneDX purls but omits Maven groups in JSON names:
# https://github.com/google/osv-scanner/issues/3099
# Fail on ambiguous display coordinates instead of claiming full identity proof.
projected = Counter((name.split(":", 1)[1], version) for name, version in expected)
if any(count != 1 for count in projected.values()):
raise ValueError("Ambiguous Maven coordinates in pinned OSV report format")
scanned: Counter[tuple[str, str]] = Counter()
for raw_result in cast(list[object], typed_report.get("results")):
result = cast(OsvResult, raw_result)
if not isinstance(raw_result, dict) or not isinstance(result.get("source"), dict):
raise ValueError("Missing OSV source")
source = cast(dict[str, str], result.get("source"))
if source.get("type") != "sbom" or not isinstance(source.get("path"), str):
raise ValueError("Expected OSV SBOM source")
if not isinstance(result.get("packages"), list):
raise ValueError("Missing OSV package inventory; use --all-packages")
for raw_entry in cast(list[object], result.get("packages")):
entry = cast(OsvEntry, raw_entry)
if not isinstance(raw_entry, dict) or not isinstance(entry.get("package"), dict):
raise ValueError("Malformed OSV package")
package = cast(dict[str, str], entry.get("package"))
if package.get("ecosystem") != "Maven" or not all(isinstance(package.get(k), str) and package[k] for k in ("name", "version")):
raise ValueError("Invalid OSV Maven package coordinate")
if "vulnerabilities" in entry and not isinstance(entry["vulnerabilities"], list):
raise ValueError("Malformed OSV vulnerabilities")
scanned[(package["name"], package["version"])] += 1
if scanned != projected:
raise ValueError("OSV --all-packages inventory differs from selected Maven graph")
return len(expected)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("resolved", type=Path)
parser.add_argument("bom", type=Path)
parser.add_argument("trivy", type=Path, nargs="?")
parser.add_argument("--osv", type=Path)
args = parser.parse_args()
expected = resolved(args.resolved.read_text(encoding="utf-8"))
count = check(expected,
json.loads(args.bom.read_text(encoding="utf-8")),
json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
if args.osv:
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
def main() -> None:
"""Validate the exact evidence files supplied by the required CI job."""
parser = argparse.ArgumentParser()
parser.add_argument("resolved", type=Path)
parser.add_argument("bom", type=Path)
parser.add_argument("trivy", type=Path, nargs="?")
parser.add_argument("--osv", type=Path)
args = parser.parse_args()
expected = resolved(args.resolved.read_text(encoding="utf-8"))
count = check(expected, json.loads(args.bom.read_text(encoding="utf-8")), json.loads(args.trivy.read_text(encoding="utf-8")) if args.trivy else OMITTED)
if args.osv:
check_osv(expected, json.loads(args.osv.read_text(encoding="utf-8")))
print(f"Verified {count} Maven runtime dependencies, including the UI frontend.")
if __name__ == "__main__":
main()
main()
+112
View File
@@ -0,0 +1,112 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Fail closed unless every tracked owned script is covered by, and really analyzed by, the quality tools."""
import json
import shutil
import subprocess
import sys
from pathlib import Path, PurePosixPath
from typing import Any
PYTHON_SUFFIXES = (".py", ".pyi", ".pyw")
SCRIPT_PATTERN = ".github/scripts/*.py"
PYRIGHT_CONFIG = ".github"
JAVASCRIPT_SUFFIXES = (".js", ".cjs", ".mjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
# Microsoft's official npm Pyright, restored from package-lock.json by `npm ci --ignore-scripts`.
PYRIGHT_ENTRY = Path(__file__).resolve().parents[2] / "node_modules" / "pyright" / "index.js"
def tracked_files(root: Path) -> list[str]:
"""List tracked and not-yet-ignored new paths so no source file can silently bypass the gates."""
# Fixed Git inventory operation; the root is an argument, never shell input.
output = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"], check=True, capture_output=True).stdout.decode("utf-8") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
return [name for name in output.split("\u0000") if name]
def python_scope(tracked: list[str]) -> list[str]:
"""Return the tracked Python sources, rejecting any outside the one checked script root."""
candidates = sorted(name for name in tracked if name.endswith(PYTHON_SUFFIXES))
outside = [name for name in candidates if not PurePosixPath(name).full_match(SCRIPT_PATTERN)]
if outside:
raise ValueError("Tracked Python outside the checked script root: " + ", ".join(outside))
if not candidates:
raise ValueError("No owned Python scripts selected")
return candidates
def javascript_scope(root: Path, tracked: list[str]) -> list[str]:
"""Return tracked JavaScript/TypeScript sources, rejecting any outside the tsconfig include list."""
candidates = sorted(name for name in tracked if name.endswith(JAVASCRIPT_SUFFIXES))
tsconfig = root / "tsconfig.json"
if not tsconfig.is_file():
if candidates:
raise ValueError("Tracked JavaScript/TypeScript without a tsconfig.json: " + ", ".join(candidates))
return []
includes: list[str] = json.loads(tsconfig.read_text(encoding="utf-8"))["include"]
outside = [name for name in candidates if not any(PurePosixPath(name).full_match(pattern) for pattern in includes)]
if outside:
raise ValueError("Tracked JavaScript/TypeScript outside the tsconfig include list: " + ", ".join(outside))
if not candidates:
raise ValueError("No owned JavaScript sources selected")
return candidates
def run_pyright(root: Path, config: str) -> dict[str, Any]:
"""Run the locked npm Pyright through Node with JSON output and return its full report."""
node = shutil.which("node")
if node is None:
raise ValueError("Node.js is required to run the locked Pyright")
if not PYRIGHT_ENTRY.is_file():
raise ValueError(f"Locked Pyright is missing; run npm ci --ignore-scripts ({PYRIGHT_ENTRY})")
# Resolved Node and locked entry script; the config is an argument, never shell input.
completed = subprocess.run([node, str(PYRIGHT_ENTRY), "-p", config, "--outputjson"], cwd=root, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Resolved Node and locked entry script; no shell.
try:
report: dict[str, Any] = json.loads(completed.stdout[completed.stdout.index("{") :])
int(report["summary"]["filesAnalyzed"]) # Fail here when the summary shape is missing.
report["exitCode"] = completed.returncode
except (ValueError, KeyError) as error:
raise ValueError(f"Pyright produced no JSON summary (exit {completed.returncode}): {completed.stdout}{completed.stderr}") from error
return report
def check_pyright_report(report: dict[str, Any], expected: int) -> None:
"""Require the analyzed-file count to match the selected sources, with no errors or warnings."""
summary = report["summary"]
analyzed = summary["filesAnalyzed"]
if analyzed == 0 or analyzed != expected:
raise ValueError(f"Pyright analyzed {analyzed} files, expected {expected}")
if summary["errorCount"] or summary["warningCount"]:
details = [f"{item['file']}: {item['message']}" for item in report["generalDiagnostics"]]
raise ValueError("Pyright reported findings:\n" + "\n".join(details))
if report["exitCode"] != 0:
raise ValueError(f"Pyright exited {report['exitCode']} despite a clean summary")
def run_ruff(root: Path, arguments: list[str], sources: list[str]) -> None:
"""Run Ruff on the explicit source list so no selected file can be skipped."""
# Fixed interpreter module invocation; paths are arguments, never shell input.
if subprocess.run([sys.executable, "-m", "ruff", *arguments, "--no-cache", *sources], cwd=root, check=False).returncode != 0: # noqa: S603 -- Fixed interpreter and module; no shell.
raise ValueError("Ruff " + " ".join(arguments) + " failed")
def main() -> None:
"""Verify scope, then Ruff format/lint and strict Pyright with a positive analyzed count."""
root = Path(__file__).resolve().parents[2]
tracked = tracked_files(root)
sources = python_scope(tracked)
javascript = javascript_scope(root, tracked)
run_ruff(root, ["format", "--check"], sources)
run_ruff(root, ["check"], sources)
report = run_pyright(root, PYRIGHT_CONFIG)
check_pyright_report(report, len(sources))
print(f"Script quality verified: {len(sources)} Python scripts analyzed by Ruff and Pyright, {len(javascript)} JavaScript files in tsconfig scope")
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.CalledProcessError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)
+130
View File
@@ -0,0 +1,130 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Check leading notices in explicitly owned, Git-tracked source families."""
import fnmatch
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
def selected(name: str, policy: dict[str, Any]) -> bool:
"""Select owned paths, honoring documented exact/pattern exclusions first."""
if any(fnmatch.fnmatchcase(name, item["pattern"]) for item in policy["exclusions"]):
return False
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
def leading_comments(text: str, style: str) -> str: # noqa: PLR0912 -- Each recognized leading-comment syntax has a distinct fail-closed branch.
"""Read comments only, preserving required interpreter/XML directive ordering."""
text = text.removeprefix("\ufeff")
if text.startswith("<?xml"):
declaration = re.match(r"<\?xml[^?]*\?>", text)
if style != "xml" or declaration is None:
raise ValueError("Invalid XML declaration")
text = text[declaration.end() :]
elif text.startswith("#!"):
if style not in ("hash", "powershell", "slash"):
raise ValueError("Unexpected interpreter directive")
text = text.partition("\n")[2]
while re.match(r"#Requires\b", text, re.IGNORECASE):
if style != "powershell":
raise ValueError("Unexpected PowerShell directive")
text = text.partition("\n")[2]
comments: list[str] = []
while text.strip():
text = text.lstrip()
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
raise ValueError("Required declaration must precede the notice")
if style in ("hash", "powershell") and text.startswith("#"):
line, _, text = text.partition("\n")
comments.append(line[1:])
elif style == "slash" and text.startswith("//"):
line, _, text = text.partition("\n")
comments.append(line[2:])
elif (style == "slash" and text.startswith("/*")) or (style == "xml" and text.startswith("<!--")) or (style == "powershell" and text.startswith("<#")):
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
body, separator, rest = text[len(opening) :].partition(closing)
if not separator:
raise ValueError("Unterminated leading comment")
comments.append(body)
text = rest
else:
break
return "\n".join(re.sub(r"^\s*[*~]\s?", "", line).strip() for line in "\n".join(comments).splitlines())
def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
"""Require an approved holder/license pair in real leading comment syntax."""
suffix = Path(name).suffix
style = policy["styles"].get(suffix)
if Path(name).name.startswith("Dockerfile."):
style = "hash"
if style is None:
raise ValueError("Selected path has no configured comment syntax")
comments = leading_comments(text, style)
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
for notice in approved:
holder = re.escape(notice["holder"])
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-\u2013]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
if re.search(copyright_pattern, comments) is None:
continue
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
if spdx and any(value != notice["license"] for value in spdx):
continue
apache = all(
phrase in comments
for phrase in (
"Licensed under the Apache License, Version 2.0",
"you may not use this file except in compliance with the License.",
"https://www.apache.org/licenses/LICENSE-2.0",
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
"limitations under the License.",
)
)
if suffix == ".java" and not apache:
continue
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
return
raise ValueError("Missing or mismatched leading copyright/license notice")
def check_repository(root: Path, policy: dict[str, Any]) -> int:
"""Check tracked sources without scanning build outputs or executing source."""
# Fixed Git inventory operation; paths are arguments, never shell input.
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000") # noqa: S603, S607 -- Resolve Git from the developer/CI PATH; no shell or user-supplied command.
failures: list[str] = []
count = 0
for name in tracked:
if not name or not selected(name, policy):
continue
count += 1
try:
check_notice(name, (root / name).read_text(encoding="utf-8-sig"), policy)
except (ValueError, OSError) as error:
failures.append(f"{name}: {error}")
if count == 0:
raise ValueError("No owned source files selected")
if failures:
raise ValueError("\n".join(failures))
return count
def main() -> None:
"""Load the repository-specific reviewed scope and fail CI on a bad notice."""
root = Path(__file__).resolve().parents[2]
policy = json.loads((root / ".github/source-headers.json").read_text(encoding="utf-8"))
count = check_repository(root, policy)
print(f"Leading source notices verified: {count} tracked files")
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.CalledProcessError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)
+90 -77
View File
@@ -1,105 +1,118 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Synthetic regressions for missing, incorrect and empty scanner inventories."""
import copy
import importlib.util
from pathlib import Path
import sys
import subprocess
import tempfile
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any, cast, override
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("inventory", Path(__file__).with_name("check-maven-inventory.py"))
assert spec is not None and spec.loader is not None
inventory = importlib.util.module_from_spec(spec)
spec.loader.exec_module(inventory)
class InventoryTests(unittest.TestCase):
def setUp(self):
self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")}
self.text = "The following files have been resolved:\n" + "\n".join(
f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected)
self.bom = {"bomFormat": "CycloneDX", "components": [
{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"}
for name, version in self.expected]}
self.trivy = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]}
"""Exercise selected dependency coverage using isolated synthetic reports."""
def test_complete_graph_without_findings_passes(self):
self.assertEqual(self.expected, inventory.resolved(self.text))
self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy))
@override
def setUp(self) -> None:
"""Create isolated synthetic evidence with cleanup after each test."""
self.expected = {(name, "2.4.240" if name == "com.h2database:h2" else "1.0") for name in inventory.REQUIRED} | {("org.jspecify:jspecify", "1.0.0")}
self.text = "The following files have been resolved:\n" + "\n".join(f" {name}:jar:{version}:runtime\x1b[36m -- module example\x1b[m" for name, version in self.expected)
self.bom: dict[str, Any] = {"bomFormat": "CycloneDX", "components": [{"purl": "pkg:maven/" + name.replace(":", "/") + "@" + version + "?type=jar"} for name, version in self.expected]}
self.trivy: dict[str, Any] = {"Results": [{"Packages": [{"Name": n, "Version": v} for n, v in self.expected], "Vulnerabilities": []}]}
def test_missing_or_unreviewed_h2_version_fails(self):
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"),
self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
with self.subTest(text=text), self.assertRaises(ValueError):
inventory.resolved(text)
def test_complete_graph_without_findings_passes(self) -> None:
"""Verify complete graph without findings passes."""
self.assertEqual(self.expected, inventory.resolved(self.text))
self.assertEqual(6, inventory.check(self.expected, self.bom, self.trivy))
def test_missing_frontend_and_empty_maven_fail(self):
for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)):
with self.assertRaises(ValueError):
inventory.resolved(text)
def test_missing_or_unreviewed_h2_version_fails(self) -> None:
"""Verify missing or unreviewed h2 version fails."""
without_h2 = "\n".join(line for line in self.text.splitlines() if "com.h2database:h2:" not in line)
for text in (without_h2, self.text.replace("2.4.240", "2.3.232"), self.text.replace("2.4.240", "2.4.241"), self.text + "\ncom.h2database:h2:jar:2.3.232:runtime"):
with self.subTest(text=text), self.assertRaises(ValueError):
inventory.resolved(text)
def test_test_scope_or_test_jar_fails(self):
for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"):
with self.assertRaises(ValueError):
inventory.resolved(self.text + "\n" + extra)
def test_missing_frontend_and_empty_maven_fail(self) -> None:
"""Verify missing frontend and empty maven fail."""
for text in ("", "\n".join(line for line in self.text.splitlines() if "qqq-frontend-next" not in line)):
with self.assertRaises(ValueError):
inventory.resolved(text)
def test_empty_malformed_or_missing_component_fails(self):
for bom in ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]},
{"bomFormat": "CycloneDX", "components": self.bom["components"][1:]}):
with self.assertRaises(ValueError):
inventory.check(self.expected, bom)
def test_test_scope_or_test_jar_fails(self) -> None:
"""Verify test scope or test jar fails."""
for extra in ("example:test:jar:1:test", "org.junit.jupiter:junit-jupiter:jar:5:runtime"):
with self.assertRaises(ValueError):
inventory.resolved(self.text + "\n" + extra)
def test_wrong_selected_version_fails(self):
bad = copy.deepcopy(self.trivy)
for package in bad["Results"][0]["Packages"]:
if package["Name"] == "org.jspecify:jspecify":
package["Version"] = "1.0.1"
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, bad)
def test_empty_malformed_or_missing_component_fails(self) -> None:
"""Verify empty malformed or missing component fails."""
for bom in cast(tuple[object, ...], ({}, {"bomFormat": "CycloneDX", "components": []}, {"bomFormat": "CycloneDX", "components": [{}]}, {"bomFormat": "CycloneDX", "components": self.bom["components"][1:]})):
with self.assertRaises(ValueError):
inventory.check(self.expected, bom)
def test_empty_or_dropped_trivy_package_fails(self):
for report in (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]}):
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, report)
def test_wrong_selected_version_fails(self) -> None:
"""Verify wrong selected version fails."""
bad = copy.deepcopy(self.trivy)
for package in bad["Results"][0]["Packages"]:
if package["Name"] == "org.jspecify:jspecify":
package["Version"] = "1.0.1"
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, bad)
def test_cli_supplied_null_trivy_fails(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
(directory / "resolved.txt").write_text(self.text, encoding="utf-8")
(directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8")
(directory / "trivy.json").write_text("null", encoding="utf-8")
result = subprocess.run([sys.executable, str(Path(inventory.__file__)),
str(directory / "resolved.txt"), str(directory / "bom.json"),
str(directory / "trivy.json")], capture_output=True, text=True, check=False)
self.assertNotEqual(0, result.returncode)
self.assertIn("Expected Trivy report object", result.stderr)
def test_empty_or_dropped_trivy_package_fails(self) -> None:
"""Verify empty or dropped trivy package fails."""
for report in cast(tuple[object, ...], (None, {}, {"Results": []}, {"Results": [{"Packages": self.trivy["Results"][0]["Packages"][1:]}]})):
with self.assertRaises(ValueError):
inventory.check(self.expected, self.bom, report)
def osv(self):
return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [
{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}}
for name, version in self.expected]}]}
def test_cli_supplied_null_trivy_fails(self) -> None:
"""Verify cli supplied null trivy fails."""
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
(directory / "resolved.txt").write_text(self.text, encoding="utf-8")
(directory / "bom.json").write_text(json.dumps(self.bom), encoding="utf-8")
(directory / "trivy.json").write_text("null", encoding="utf-8")
assert inventory.__file__ is not None
result = subprocess.run([sys.executable, str(Path(inventory.__file__)), str(directory / "resolved.txt"), str(directory / "bom.json"), str(directory / "trivy.json")], capture_output=True, text=True, check=False) # noqa: S603 -- Execute this interpreter and the fixed sibling guard using synthetic paths, without a shell.
self.assertNotEqual(0, result.returncode)
self.assertIn("Expected Trivy report object", result.stderr)
def test_osv_all_clean_packages_pass_without_vulnerabilities(self):
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
def osv(self) -> dict[str, Any]:
"""Build a clean OSV fixture whose identity matches the selected graph."""
return {"results": [{"source": {"path": "/src/printing.cdx.json", "type": "sbom"}, "packages": [{"package": {"name": name.split(":", 1)[1], "version": version, "ecosystem": "Maven"}} for name, version in self.expected]}]}
def test_osv_missing_malformed_or_partial_evidence_fails(self):
partial = self.osv()
partial["results"][0]["packages"].pop()
wrong = self.osv()
wrong["results"][0]["packages"][0]["package"]["version"] = "wrong"
duplicate = self.osv()
duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0])
for report in (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate):
with self.subTest(report=report), self.assertRaises(ValueError):
inventory.check_osv(self.expected, report)
def test_osv_all_clean_packages_pass_without_vulnerabilities(self) -> None:
"""Verify osv all clean packages pass without vulnerabilities."""
self.assertEqual(6, inventory.check_osv(self.expected, self.osv()))
def test_osv_ambiguous_group_projection_fails(self):
with self.assertRaises(ValueError):
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
def test_osv_missing_malformed_or_partial_evidence_fails(self) -> None:
"""Verify osv missing malformed or partial evidence fails."""
partial = self.osv()
partial["results"][0]["packages"].pop()
wrong = self.osv()
wrong["results"][0]["packages"][0]["package"]["version"] = "wrong"
duplicate = self.osv()
duplicate["results"][0]["packages"].append(duplicate["results"][0]["packages"][0])
for report in cast(tuple[object, ...], (None, {}, [], {"results": None}, {"results": []}, {"results": [{}]}, partial, wrong, duplicate)):
with self.subTest(report=report), self.assertRaises(ValueError):
inventory.check_osv(self.expected, report)
def test_osv_ambiguous_group_projection_fails(self) -> None:
"""Verify osv ambiguous group projection fails."""
with self.assertRaises(ValueError):
inventory.check_osv(self.expected | {("other:jspecify", "1.0.0")}, self.osv())
if __name__ == "__main__":
unittest.main()
unittest.main()
+79 -61
View File
@@ -1,89 +1,107 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Exercise the exact CI guards using synthetic evidence only."""
import contextlib
import copy
import io
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from typing import Any, override
sys.dont_write_bytecode = True
def load(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load(name: str) -> ModuleType:
"""Load the real sibling guard for synthetic evidence tests."""
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
codeql = load("check-codeql-sarif")
class SarifTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.directory = Path(self.temporary.name)
self.report = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
"""Reject incomplete SARIF evidence and preserve severity enforcement."""
def save(self, report):
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
@override
def setUp(self) -> None:
"""Create isolated synthetic evidence with cleanup after each test."""
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.directory = Path(self.temporary.name)
self.report: dict[str, Any] = {"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "CodeQL", "rules": [{"id": "synthetic", "properties": {"security-severity": "8.0"}}]}}, "results": []}]}
def test_clean_report_with_zero_findings_passes(self):
self.save(self.report)
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
def save(self, report: object) -> None:
"""Write the test body as SARIF without validating it first."""
(self.directory / "java.sarif").write_text(json.dumps(report), encoding="utf-8")
def test_missing_report_or_wrong_language_fails(self):
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "csharp", 7)
def test_clean_report_with_zero_findings_passes(self) -> None:
"""Verify clean report with zero findings passes."""
self.save(self.report)
self.assertFalse(codeql.check(self.directory, "java-kotlin", 7))
def test_empty_or_malformed_report_fails(self):
for text in ["", "{", "null", "[]"]:
with self.subTest(text=text):
(self.directory / "java.sarif").write_text(text)
with self.assertRaises((ValueError, AttributeError)):
codeql.check(self.directory, "java-kotlin", 7)
def test_missing_report_or_wrong_language_fails(self) -> None:
"""Verify missing report or wrong language fails."""
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "csharp", 7)
def test_missing_runs_tool_rules_or_results_fails(self):
variants = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
for field in ["tool", "results"]:
bad = copy.deepcopy(self.report)
del bad["runs"][0][field]
variants.append(bad)
bad = copy.deepcopy(self.report)
bad["runs"][0]["tool"]["driver"]["rules"] = []
variants.append(bad)
for bad in variants:
with self.subTest(report=bad):
self.save(bad)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_empty_or_malformed_report_fails(self) -> None:
"""Verify empty or malformed report fails."""
for text in ["", "{", "null", "[]"]:
with self.subTest(text=text):
(self.directory / "java.sarif").write_text(text)
with self.assertRaises((ValueError, AttributeError)):
codeql.check(self.directory, "java-kotlin", 7)
def test_high_finding_blocks_and_unknown_rule_fails(self):
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
self.save(self.report)
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_missing_runs_tool_rules_or_results_fails(self) -> None:
"""Verify missing runs tool rules or results fails."""
variants: list[dict[str, Any]] = [{"version": "2.1.0"}, {"version": "2.1.0", "runs": []}]
for field in ["tool", "results"]:
bad = copy.deepcopy(self.report)
del bad["runs"][0][field]
variants.append(bad)
bad = copy.deepcopy(self.report)
bad["runs"][0]["tool"]["driver"]["rules"] = []
variants.append(bad)
for bad in variants:
with self.subTest(report=bad):
self.save(bad)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_failed_invocation_and_invalid_threshold_fail(self):
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", float("nan"))
def test_high_finding_blocks_and_unknown_rule_fails(self) -> None:
"""Verify high finding blocks and unknown rule fails."""
self.report["runs"][0]["results"] = [{"ruleId": "synthetic"}]
self.save(self.report)
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(codeql.check(self.directory, "java-kotlin", 7))
self.report["runs"][0]["results"] = [{"ruleId": "missing"}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
def test_failed_invocation_and_invalid_threshold_fail(self) -> None:
"""Verify failed invocation and invalid threshold fail."""
self.report["runs"][0]["invocations"] = [{"executionSuccessful": False}]
self.save(self.report)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", 7)
with self.assertRaises(ValueError):
codeql.check(self.directory, "java-kotlin", float("nan"))
if __name__ == "__main__":
unittest.main()
unittest.main()
+238
View File
@@ -0,0 +1,238 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Prove the quality gates reject real violations, using private temporary fixtures and the locked tools."""
import importlib.util
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from typing import Any, override
from unittest import mock
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
GOOD_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
UNUSED_IMPORT_PYTHON = '"""Probe module."""\n\nimport os\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
TWO_SPACE_PYTHON = '"""Probe module."""\n\n\ndef add(left: int, right: int) -> int:\n """Add two integers."""\n return left + right\n'
INCOMPATIBLE_PYTHON = '"""Probe module."""\n\nVALUE: int = "text"\n'
UNANNOTATED_PYTHON = '"""Probe module."""\n\n\ndef echo(value):\n """Return the value."""\n return value\n'
GOOD_JAVASCRIPT = "export function addNumbers(left, right)\n{\n return left + right;\n}\n"
BAD_LAYOUT_JAVASCRIPT = "export function addNumbers(left, right) {\n return left + right\n}\n"
GOOD_TYPED_JAVASCRIPT = "/** @type {number} */\nexport const value = 1;\n"
INCOMPATIBLE_JAVASCRIPT = '/** @type {number} */\nexport const value = "text";\n'
def load(name: str) -> ModuleType:
"""Load the real sibling guard for tests."""
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + ".py"))
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
guard = load("check-script-quality")
def run(command: list[str], cwd: Path, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
"""Run one real tool invocation without a shell and capture its diagnostics."""
return subprocess.run(command, cwd=cwd, input=stdin, check=False, capture_output=True, text=True, encoding="utf-8") # noqa: S603 -- Fixed tool paths and private temporary arguments; no shell.
def ruff(arguments: list[str], source: Path) -> subprocess.CompletedProcess[str]:
"""Run the locked Ruff with the repository configuration on one private fixture."""
return run([sys.executable, "-m", "ruff", *arguments, "--no-cache", "--config", str(ROOT / "ruff.toml"), str(source)], source.parent)
class ScopeTests(unittest.TestCase):
"""Reject tracked sources that fall outside the explicit tool roots."""
def test_python_outside_script_root_is_rejected(self) -> None:
"""Reject a tracked Python file outside the script root instead of letting it disappear."""
for stray in ("tools/helper.py", ".github/scripts/nested/helper.py", "helper.pyi"):
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the checked script root"):
guard.python_scope([".github/scripts/check-a.py", stray])
def test_python_scope_returns_sorted_script_root_and_rejects_empty(self) -> None:
"""Select only the direct script root, sorted, and reject selecting nothing."""
self.assertEqual([".github/scripts/a.py", ".github/scripts/b.py"], guard.python_scope([".github/scripts/b.py", "README.md", ".github/scripts/a.py"]))
with self.assertRaisesRegex(ValueError, "No owned Python"):
guard.python_scope(["README.md"])
def test_javascript_requires_tsconfig_include_scope(self) -> None:
"""Reject tracked JavaScript outside tsconfig include patterns, including .js/.cjs/.ts."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
(root / "tsconfig.json").write_text(json.dumps({"include": ["tools/**/*.mjs", "eslint.config.mjs"]}), encoding="utf-8")
self.assertEqual(["eslint.config.mjs", "tools/a/b.mjs", "tools/c.mjs"], guard.javascript_scope(root, ["tools/c.mjs", "tools/a/b.mjs", "eslint.config.mjs", "README.md"]))
for stray in ("src/z.js", "tools/w.cjs", "tools/v.ts", "tools/u.js"):
with self.subTest(stray=stray), self.assertRaisesRegex(ValueError, "outside the tsconfig include list"):
guard.javascript_scope(root, ["tools/c.mjs", stray])
with self.assertRaisesRegex(ValueError, "No owned JavaScript"):
guard.javascript_scope(root, ["README.md"])
def test_javascript_without_tsconfig_is_rejected(self) -> None:
"""Reject JavaScript in a repository that has no tsconfig."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
self.assertEqual([], guard.javascript_scope(root, ["README.md"]))
with self.assertRaisesRegex(ValueError, "without a tsconfig.json"):
guard.javascript_scope(root, ["tools/a.mjs"])
class PyrightCountTests(unittest.TestCase):
"""Reject Pyright reports that analyzed too few files or found anything."""
@staticmethod
def report(analyzed: int, errors: int = 0, warnings: int = 0) -> dict[str, Any]:
"""Build the exact Pyright JSON shape consumed by the guard."""
return {"summary": {"filesAnalyzed": analyzed, "errorCount": errors, "warningCount": warnings}, "generalDiagnostics": [{"file": "x.py", "message": "finding"}] * (errors + warnings), "exitCode": 1 if errors or warnings else 0}
def test_nonzero_exit_fails_even_with_clean_summary(self) -> None:
"""Fail a nonzero Pyright exit even when every count is clean."""
clean = self.report(4)
clean["exitCode"] = 1
with self.assertRaisesRegex(ValueError, "exited 1"):
guard.check_pyright_report(clean, 4)
def test_count_must_match_selected_sources(self) -> None:
"""Fail zero or mismatched analyzed counts even with no diagnostics."""
guard.check_pyright_report(self.report(4), 4)
for analyzed in (0, 3, 5):
with self.subTest(analyzed=analyzed), self.assertRaisesRegex(ValueError, "analyzed"):
guard.check_pyright_report(self.report(analyzed), 4)
def test_errors_and_warnings_fail(self) -> None:
"""Fail on errors and on warnings at the exact count."""
for report in (self.report(4, errors=1), self.report(4, warnings=1)):
with self.subTest(report=report), self.assertRaisesRegex(ValueError, "reported findings"):
guard.check_pyright_report(report, 4)
class RuffProbeTests(unittest.TestCase):
"""Run the real locked Ruff on bounded private fixtures."""
def test_compliant_fixture_passes_and_violations_fail(self) -> None:
"""Pass a compliant control and reject unused imports and wrong indentation."""
with tempfile.TemporaryDirectory() as name:
probe = Path(name) / "probe.py"
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
self.assertEqual(0, ruff(["check"], probe).returncode)
self.assertEqual(0, ruff(["format", "--check"], probe).returncode)
probe.write_text(UNUSED_IMPORT_PYTHON, encoding="utf-8", newline="\n")
unused = ruff(["check", "--output-format", "concise"], probe)
self.assertNotEqual(0, unused.returncode)
self.assertIn("F401", unused.stdout)
probe.write_text(TWO_SPACE_PYTHON, encoding="utf-8", newline="\n")
indented = ruff(["format", "--check"], probe)
self.assertNotEqual(0, indented.returncode)
self.assertIn("would be reformatted", (indented.stdout + indented.stderr).lower())
class PyrightProbeTests(unittest.TestCase):
"""Run the real locked Pyright with the repository strict settings on private projects."""
@staticmethod
def project(root: Path, include: list[str] | None = None) -> None:
"""Write the repository Pyright config, without its venv location, into a private project."""
config: dict[str, Any] = json.loads((ROOT / ".github/pyrightconfig.json").read_text(encoding="utf-8"))
del config["venvPath"], config["venv"]
if include is not None:
config["include"] = include
(root / "pyrightconfig.json").write_text(json.dumps(config), encoding="utf-8")
def test_compliant_control_passes_and_strict_violations_fail(self) -> None:
"""Analyze the control once with no findings and reject incompatible and unannotated code."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
self.project(root)
scripts = root / "scripts"
scripts.mkdir()
probe = scripts / "probe.py"
probe.write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
clean = guard.run_pyright(root, ".")
self.assertEqual(0, clean["exitCode"])
guard.check_pyright_report(clean, 1)
clean["exitCode"] = 1
with self.assertRaisesRegex(ValueError, "exited 1"):
guard.check_pyright_report(clean, 1)
probe.write_text(INCOMPATIBLE_PYTHON, encoding="utf-8", newline="\n")
report = guard.run_pyright(root, ".")
self.assertIn("reportAssignmentType", {item.get("rule") for item in report["generalDiagnostics"]})
with self.assertRaisesRegex(ValueError, "reported findings"):
guard.check_pyright_report(report, 1)
probe.write_text(UNANNOTATED_PYTHON, encoding="utf-8", newline="\n")
strict = guard.run_pyright(root, ".")
self.assertIn("reportMissingParameterType", {item.get("rule") for item in strict["generalDiagnostics"]})
def test_missing_locked_pyright_or_node_is_rejected(self) -> None:
"""Reject a missing locked Pyright entry or Node instead of silently skipping the type gate."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
with mock.patch.object(guard, "PYRIGHT_ENTRY", root / "missing.js"), self.assertRaisesRegex(ValueError, "Locked Pyright is missing"):
guard.run_pyright(root, ".")
with mock.patch.object(guard.shutil, "which", return_value=None), self.assertRaisesRegex(ValueError, "Node.js is required"):
guard.run_pyright(root, ".")
def test_zero_analyzed_files_cannot_pass(self) -> None:
"""Reject an empty include and a hidden-directory root config in the wrapper."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
scripts = root / ".github" / "scripts"
scripts.mkdir(parents=True)
(scripts / "probe.py").write_text(GOOD_PYTHON, encoding="utf-8", newline="\n")
for include in (["nothing/*.py"], [".github/scripts/*.py"]):
self.project(root, include)
with self.subTest(include=include), self.assertRaises(ValueError):
guard.check_pyright_report(guard.run_pyright(root, "."), 1)
class JavaScriptProbeTests(unittest.TestCase):
"""Run the real locked ESLint and TypeScript on bounded private input."""
@override
def setUp(self) -> None:
"""Skip only where tsconfig is absent, because the scope guard forbids JavaScript there."""
if not (ROOT / "tsconfig.json").is_file():
self.skipTest("No JavaScript tooling in this repository")
node = shutil.which("node")
self.assertIsNotNone(node)
self.node = str(node)
def eslint(self, text: str) -> subprocess.CompletedProcess[str]:
"""Lint text as an in-scope tool file using the repository configuration."""
return run([self.node, str(ROOT / "node_modules/eslint/bin/eslint.js"), "--stdin", "--stdin-filename", "tools/probe.mjs", "--max-warnings", "0", "--format", "json"], ROOT, text)
def test_eslint_rejects_wrong_layout(self) -> None:
"""Pass a compliant control and reject K&R braces, two-space indent and a missing semicolon."""
good = self.eslint(GOOD_JAVASCRIPT)
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
bad = self.eslint(BAD_LAYOUT_JAVASCRIPT)
self.assertNotEqual(0, bad.returncode)
rules = {message["ruleId"] for message in json.loads(bad.stdout)[0]["messages"]}
self.assertLessEqual({"@stylistic/brace-style", "@stylistic/semi", "@stylistic/indent"}, rules)
def test_checked_javascript_rejects_incompatible_assignment(self) -> None:
"""Reject a real incompatible assignment under the repository strict checkJs settings."""
with tempfile.TemporaryDirectory() as name:
root = Path(name)
(root / "tsconfig.json").write_text(json.dumps({"extends": (ROOT / "tsconfig.json").as_posix(), "compilerOptions": {"typeRoots": [(ROOT / "node_modules/@types").as_posix()]}, "include": ["probe.mjs"]}), encoding="utf-8")
tsc = [self.node, str(ROOT / "node_modules/typescript/bin/tsc"), "-p", str(root / "tsconfig.json")]
(root / "probe.mjs").write_text(GOOD_TYPED_JAVASCRIPT, encoding="utf-8", newline="\n")
good = run(tsc, root)
self.assertEqual(0, good.returncode, good.stdout + good.stderr)
(root / "probe.mjs").write_text(INCOMPATIBLE_JAVASCRIPT, encoding="utf-8", newline="\n")
bad = run(tsc, root)
self.assertNotEqual(0, bad.returncode)
self.assertIn("TS2322", bad.stdout)
if __name__ == "__main__":
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Exercise notice parsing, provenance, scope and real Git inventory rejection."""
import importlib.util
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from typing import Any
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
assert SPEC is not None and SPEC.loader is not None
HEADERS = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(HEADERS)
POLICY = json.loads(Path(__file__).parents[1].joinpath("source-headers.json").read_text(encoding="utf-8"))
MIT = "Copyright (c) 2026 James Maes\nSPDX-License-Identifier: MIT"
APACHE = """Copyright (C) 2021-2025. Kingsrook, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License."""
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
"""Use the production styles with a small explicit owned fixture scope."""
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"], "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
class HeaderTests(unittest.TestCase):
"""Ensure policy failures cannot pass through text-only copyright searches."""
def test_each_real_comment_syntax(self) -> None:
"""Recognize hash, slash, block and XML notices in their own languages."""
for name, text in (
("src/a.py", "# " + MIT.replace("\n", "\n# ") + "\nprint(1)"),
("src/a.cs", "// " + MIT.replace("\n", "\n// ") + "\nclass A {}"),
("src/a.mjs", "/*\n * " + MIT.replace("\n", "\n * ") + "\n */\nexport {};"),
("src/a.xml", "<!--\n" + MIT + "\n-->\n<root/>"),
("src/a.ps1", "<#\n" + MIT + "\n#>\nWrite-Output 1"),
):
with self.subTest(name=name):
HEADERS.check_notice(name, text, fixture_policy())
def test_year_range_separator_is_hyphen_or_en_dash_only(self) -> None:
"""Accept a hyphen or en dash between years and reject any other separator."""
for separator, accepted in (("-", True), (chr(0x2013), True), ("/", False), (chr(0x2014), False)):
text = f"# Copyright 2025{separator}2026 James Maes\n# SPDX-License-Identifier: MIT"
with self.subTest(separator=separator):
if accepted:
HEADERS.check_notice("src/a.py", text, fixture_policy())
else:
with self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", text, fixture_policy())
def test_required_first_lines_and_bom(self) -> None:
"""Accept shebangs, PowerShell requirements and XML declarations first."""
for name, prefix, body in (
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
("src/a.xml", '<?xml version="1.0"?>\n', "<!--" + MIT + "--><root/>"),
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
):
with self.subTest(name=name):
HEADERS.check_notice(name, "\ufeff" + prefix + body, fixture_policy())
def test_misordered_required_first_lines_fail(self) -> None:
"""A notice must not displace an interpreter or XML declaration."""
for name, text in (
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
("src/a.xml", "<!--" + MIT + '--><?xml version="1.0"?><root/>'),
):
with self.subTest(name=name), self.assertRaises(ValueError):
HEADERS.check_notice(name, text, fixture_policy())
def test_code_strings_and_late_comments_do_not_count(self) -> None:
"""Reject docstrings and comments after executable declarations."""
for text in ('notice = "' + MIT.replace("\n", "\\n") + '"', '"""' + MIT + '"""', "import os\n# " + MIT.replace("\n", "\n# ")):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", text, fixture_policy())
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
"""Notice text alone is insufficient without a complete language comment."""
for text in ("# " + MIT, "/*" + MIT, 'string text = "' + MIT + '";'):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.cs", text, fixture_policy())
with self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", "<#" + MIT + "#>", fixture_policy())
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
"""Do not conflate repository licenses or accept conflicting identifiers."""
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"), MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
def test_complete_legacy_apache_without_spdx(self) -> None:
"""Retain valid legacy year ranges and complete Apache notices."""
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
HEADERS.check_notice("src/A.java", "/*" + APACHE + "*/\nclass A {}", policy)
HEADERS.check_notice("src/a.xml", "<!--" + APACHE + "--><root/>", policy)
def test_java_requires_full_apache_block(self) -> None:
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0", APACHE.replace("limitations under the License.", "")):
with self.subTest(notice=notice), self.assertRaises(ValueError):
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
def test_virtual_agent_provenance_is_path_scoped(self) -> None:
"""Only the owned virtual-agent family may use its existing QRun.IO holder."""
policy = fixture_policy()
policy["notices"].append({"holder": "QRun.IO, Inc.", "license": "MIT", "paths": ["tools/virtual-agent/*"]})
text = "/*Copyright 2026 QRun.IO, Inc. Licensed under the MIT License (see LICENSE).\nSPDX-License-Identifier: MIT*/"
HEADERS.check_notice("tools/virtual-agent/a.mjs", text, policy)
with self.assertRaises(ValueError):
HEADERS.check_notice("src/a.mjs", text, policy)
def test_current_scope_and_documented_exclusions(self) -> None:
"""New scripts enter the guard; data, generated and upstream files do not."""
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json", "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml", "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
with self.subTest(name=name):
self.assertFalse(HEADERS.selected(name, POLICY))
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
def test_guard_sources_match_this_repository_provenance(self) -> None:
"""Check copied guard files against local policy even before Git staging."""
for filename in ("check-source-headers.py", "test-source-headers.py"):
path = Path(__file__).with_name(filename)
name = ".github/scripts/" + filename
self.assertTrue(HEADERS.selected(name, POLICY))
HEADERS.check_notice(name, path.read_text(encoding="utf-8"), POLICY)
def test_real_git_inventory_rejects_missing_tracked_header(self) -> None:
"""A tracked new source fails while untracked generated output is ignored."""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True) # noqa: S603, S607 -- Fixed Git command in the owned temporary test directory.
root.joinpath("src").mkdir()
source = root / "src/a.py"
source.write_text("print(1)", encoding="utf-8")
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True) # noqa: S603, S607 -- Fixed synthetic path; never a shell command.
with self.assertRaisesRegex(ValueError, "src/a.py"):
HEADERS.check_repository(root, fixture_policy())
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
root.joinpath("src/untracked.py").write_text("print(2)", encoding="utf-8")
self.assertEqual(1, HEADERS.check_repository(root, fixture_policy()))
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
{
"description": "Scope of the reviewed 2026-11-01 owned-source notice pass. Python fnmatch patterns match slash-containing relative paths. Native C# IDE0073 and full Java Apache conventions remain in force.",
"includes": [
".github/*.yml",
".github/*.yaml",
".github/scripts/*.py",
"pom.xml",
"checkstyle/*.xml",
"dev/*.ps1",
"dev/*.cs",
"dev/*.csproj",
"dev/*.java",
"dev/*.sh",
"dev/*.yaml",
"dev/*.xml",
"dev/Dockerfile.*",
"dev/native-auth/Dockerfile.*",
"src/main/java/*.java",
"src/test/java/*.java",
"src/test/resources/log4j2-test.xml"
],
"exclusions": [
{
"pattern": "*.json",
"reason": "Schema, fixtures, manifests and lock data: repository LICENSE/NOTICE, no comments."
},
{
"pattern": "*.lock",
"reason": "Dependency lock data, not owned executable source."
},
{
"pattern": "*/generated/*",
"reason": "Explicit generated-code directory."
},
{
"pattern": "*/bin/*",
"reason": "Generated build output."
},
{
"pattern": "*/obj/*",
"reason": "Generated build output."
},
{
"pattern": "*/node_modules/*",
"reason": "Third-party dependency output."
},
{
"pattern": ".mvn/*",
"reason": "Upstream Maven wrapper provenance."
},
{
"pattern": "mvnw*",
"reason": "Upstream Maven wrapper provenance."
},
{
"pattern": "Assets/Legal/*",
"reason": "Byte-exact third-party legal files."
},
{
"pattern": "src/main/resources/*",
"reason": "Bundled font/legal assets and OpenAPI schema data."
},
{
"pattern": "src/test/resources/openapi/*",
"reason": "Historical contract schema fixtures."
},
{
"pattern": "*.slnx",
"reason": "Solution metadata outside the completed source-header pass; root LICENSE/NOTICE applies."
},
{
"pattern": "*.manifest",
"reason": "Application activation metadata outside this pass; root LICENSE/NOTICE applies."
},
{
"pattern": "*.runsettings",
"reason": "Test runner metadata outside this pass; root LICENSE/NOTICE applies."
},
{
"pattern": ".editorconfig",
"reason": "Style configuration outside this pass; root LICENSE/NOTICE applies."
}
],
"styles": {
".py": "hash",
".ps1": "powershell",
".psd1": "powershell",
".sh": "hash",
".yml": "hash",
".yaml": "hash",
".cs": "slash",
".java": "slash",
".mjs": "slash",
".js": "slash",
".xml": "xml",
".props": "xml",
".csproj": "xml",
".wxs": "xml",
".wixproj": "xml",
".xaml": "xml"
},
"notices": [
{
"paths": [
"*"
],
"license": "Apache-2.0",
"holder": "Kingsrook, LLC"
}
]
}
+28 -1
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: Build and prerelease
on:
@@ -26,15 +29,38 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq-print-protocol
ref: f3cbc02e9b0fb4fdfefaa59d976c04df747cc589
ref: 42c0d249f81395f00408ec0f92638dcbbf5deb03
path: qqq-print-protocol
persist-credentials: false
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: '21'
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24'
package-manager-cache: false
- name: Restore locked Pyright
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Set up uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: '0.13.6'
enable-cache: false
- name: Restore locked Python tools
run: uv sync --locked
- name: Verify script quality gates and rejection paths
run: |
uv run --locked --no-sync python .github/scripts/test-script-quality.py
uv run --locked --no-sync python .github/scripts/check-script-quality.py
- name: Verify owned source notices and rejection paths
run: |
python3 .github/scripts/test-source-headers.py
python3 .github/scripts/check-source-headers.py
- name: Checkstyle, unit tests and HTTP contract tests
run: bash ./mvnw --batch-mode --no-transfer-progress -Pui-demo verify
- name: Prove main and test compiler safety gates reject invalid source
run: python3 .github/scripts/check-java-compiler.py
- name: Prepare JAR, POM and checksums
shell: bash
run: |
@@ -60,6 +86,7 @@ jobs:
path: |
qbit-printing/target/surefire-reports/
qbit-printing/target/checkstyle-result.xml
qbit-printing/target/compiler-guard/
if-no-files-found: ignore
release-security:
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
# Pins, inputs and analysis checks preserved; report validation now fails closed.
# Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: codeql
on:
pull_request:
+109
View File
@@ -0,0 +1,109 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: development
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
inputs:
native-auth:
description: Run the opt-in, unpublished native-auth candidate qualification
type: boolean
default: false
permissions:
contents: read
concurrency:
group: development-${{ github.ref }}
cancel-in-progress: false
jobs:
scripts:
name: development-scripts (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Analyze and test development scripts
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
Import-Module Pester -RequiredVersion 5.7.1
$issues = @(Invoke-ScriptAnalyzer -Path dev -Recurse -Severity Warning,Error)
if ($issues.Count) { $issues | Format-Table; throw 'Development script analysis failed.' }
$configuration = New-PesterConfiguration
$configuration.Run.Path = './dev/tests'
$configuration.Run.Exit = $true
$configuration.TestRegistry.Enabled = $false
Invoke-Pester -Configuration $configuration
clients:
name: development-clients (${{ matrix.fixture }})
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- fixture: workload
dockerfile: dev/Dockerfile.client
- fixture: native-auth
dockerfile: dev/native-auth/Dockerfile.client
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build strict fixtures and run fake-HTTP discovery checks
env:
DOCKERFILE: ${{ matrix.dockerfile }}
run: docker build --target build --file "$DOCKERFILE" .
servers:
name: development-servers (${{ matrix.fixture }})
runs-on: ubuntu-24.04
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
include:
- fixture: workload
dockerfile: dev/Dockerfile.server
- fixture: native-auth
dockerfile: dev/native-auth/Dockerfile.server
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build strict Maven project and compile the fixture Java without starting it
env:
DOCKERFILE: ${{ matrix.dockerfile }}
run: docker build --target build --file "$DOCKERFILE" .
docker:
name: development-docker
runs-on: ubuntu-24.04
timeout-minutes: 50
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run disposable S3 and network printing smoke tests
shell: pwsh
run: ./dev/Invoke-Dev.ps1 -Action Test
- name: Run opt-in real native provider qualification
if: github.event_name == 'workflow_dispatch' && inputs.native-auth
shell: pwsh
run: ./dev/Invoke-Dev.ps1 -Action Test -ExperimentalNativeAuth
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Local copy of KofTwentyTwo/standards security.yml at
# fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
# Scanner pins and security gates are preserved. SCA scans Maven-selected runtime
+10
View File
@@ -12,3 +12,13 @@ dist/
*.iml
.DS_Store
Thumbs.db
# Local script-quality environments and tool caches.
.venv/
node_modules/
.ruff_cache/
__pycache__/
# Isolated .NET developer fixture output.
dev/**/bin/
dev/**/obj/
+1
View File
@@ -0,0 +1 @@
3.13
+334
View File
@@ -1,5 +1,339 @@
# Changelog
## Unreleased (1.2.0)
Print jobs have a real creation date-time. No wire or contract change (extension 2.4.0, wire
v1, subprotocol unchanged); connectors need no update. **Schema change: a host that owns the
printing schema must add one column before deploying this version** (below).
- `printing_job.created_at` is ISO-8601 text and its QQQ field is a string, so QQQ could not
apply a relative date filter ("created in the last day"), a date sort or a date bucket to
print jobs. The table gains `created_time`, a `TIMESTAMP` without time zone holding UTC
(the QQQ date-time convention), written by the same `INSERT` from the same instant as
`created_at`. It is the read-only QQQ field **Created** (`created_time`, `DATE_TIME`) and
replaces the text field in the Print jobs default columns and Summary section.
- `created_at` is unchanged: the queue order, history cursors, admission seeding, the Printing
home and the `createdAt` value of the job API still read it. Its QQQ field stays in Details,
relabelled **Created at (ISO text)**.
- `initialize()` adds the column (`ADD COLUMN IF NOT EXISTS`), backfills every existing row
from `created_at` on each start (so a row written by an older node during a rolling restart
is repaired) and creates `printing_job_created_time_idx (tenant_id, created_time, id)`.
- A host that owns the schema (it never calls `initialize()`) appends the equivalent to its
own migrations. For PostgreSQL, in one transaction:
```sql
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS created_time TIMESTAMP WITHOUT TIME ZONE;
UPDATE printing_job SET created_time = (created_at::timestamptz AT TIME ZONE 'UTC') WHERE created_time IS NULL;
ALTER TABLE printing_job ALTER COLUMN created_time SET DEFAULT (now() AT TIME ZONE 'UTC');
ALTER TABLE printing_job ALTER COLUMN created_time SET NOT NULL;
CREATE INDEX IF NOT EXISTS printing_job_created_time_idx ON printing_job(tenant_id, created_time, id);
```
The default and `NOT NULL` are optional hardening for a rolling deployment: a node still on
an older library omits the column, and the default gives its rows the database clock in UTC
instead of a null that date filters would skip. The library itself needs only the nullable
column and always writes it.
- On a schema without the column this version fails loudly and stores nothing: every
submission is rolled back with the database's missing-column error for `created_time`, and
QQQ cannot read the Print jobs table. Apply the schema step first; the previous library
keeps working on the new schema.
### Delete print stations and printers; "Print station"; grouped reads
No wire or contract change (extension 2.4.0, wire v1, subprotocol unchanged); connectors need
no update. **Schema change: a host that owns the printing schema applies the step below
before deploying this version.**
- **Names.** The computer that runs the print client is a **Print station** in every label,
action, preview, home page text and process message (it was "Print device", which read like
the printer). **Printer** is unchanged. The words live in `PrintingNames`; a different
choice is one edit there. Table, field and process names (`printing_device`,
`printingEnrollDevice`, ...), the HTTP API texts and the agent protocol are unchanged.
Changed labels a host may have help or tests for: tables **Print stations** and **Print
station control history**; actions **Enroll a print station**, **Disconnect print
stations**, **Reconnect print stations**, **Revoke print station access**, **Remove print
station**; the enrollment field `deviceName` is **Computer name**.
- **Delete print stations** (`printingDeleteDevices`) and **Delete printers**
(`printingDeleteSelectedPrinters`), on the list selection and on the record screen. Each
asks for a confirmation, then shows what will happen (printers that go with a station,
queued jobs canceled, jobs closed as outcome unknown, finished jobs kept) and names every
record it will not delete, with the reason. Service: `PrintingService.deleteDevice(deviceId,
actor)`, `deletePrinter(printerRowId, actor)`, and the read-only `deviceDeletionPlan` /
`printerDeletionPlan`.
- A station is revoked through the existing REVOKE control (credential rotated,
disconnected, pending commands closed) and, in the same transaction (RC.4, below; it was
a separate step before): its queued jobs become
`FAILED` ("Canceled before dispatch: its print station was deleted"); jobs it was sent
without a reported result (`CLAIMED`, `RECEIVED`, `SUBMITTING`) become `OUTCOME_UNKNOWN`;
each of those gets a `printing_event` row (`event_origin` `SERVER_DELETION`) naming the
actor; its control receipts, its printers and the station row are deleted.
- A printer: its queued jobs are canceled and its sent-but-unreported jobs closed the same
way; the printer row is deleted; its station stays in service.
- Refused, per record: a station (or a printer on a station) that holds a live credential,
shows as ONLINE or PAUSED and has jobs in `CLAIMED`, `RECEIVED` or `SUBMITTING`. Wait, or
use Disconnect or Revoke access first. A record that is gone or not the caller's is
reported and skipped. The other records of the selection are still deleted.
- Jobs are never deleted. `printing_job` gains `device_name`, `computer_name`,
`printer_name` and `queue_name`: the names the station and printer had when the job was
submitted (written by the same `INSERT`; existing jobs are filled from the current rows;
a delete fills any job that still lacks them). They are the read-only fields **Print
station**, **Computer name**, **Printer** and **Windows queue name** on Print jobs, and
the first two replace the record links in the default columns. `device_id`, `printer_id`
and `printer_row_id` keep the identifiers the deleted records had; they are no longer
references that must resolve, so the foreign key from `printing_job.device_id` to
`printing_device` is dropped.
- A computer enrolled again is a new station with a new identifier. A queue that a station
still shares is reported by its next inventory and comes back as a new printer, exposed
and not approved. Old jobs are not attached to either.
- Everything a deleted station sends afterwards (heartbeat, inventory, claim, content,
event, control poll, control receipt) and every management action on it is answered
`404 Device not found`; its token no longer authenticates (`401`). **Print again** on one
of its jobs is refused with the reason.
- New table `printing_deletion`, read-only as **Deletion history** in the Printing app: one
row per deleted station and per deleted printer with what it was (kind, name, computer
name, queue name, facility, the identifiers it had), who deleted it (`actor`), when
(`deleted_time`, a UTC timestamp) and what went with it (printers, queued jobs canceled,
jobs closed as unknown, jobs kept). It has no reference to the deleted rows.
- New permission **`printing_delete`** (`PrintingDeletePermission`). Both actions require
`printing_delete` **and** `printing_admin`; `printing_admin` alone does not delete and is
not offered the actions. A host grants it where it grants the others.
- **Remove printers**, **Remove print station** and **Revoke print station access** are
unchanged (same process names, `printing_admin`). Remove still archives (`removed_at`,
record kept); a removed record can be deleted.
- **A grouped read is a read.** `PrintingReadPermission` let a query, a get and a count
through and refused everything else as a write, so an aggregate ("print jobs by format")
was refused for everybody, administrators included, wherever permissions are enforced. It
now accepts `AggregateInput` and `ExportInput` with exactly the permission a query needs
(`printing_operator` or `printing_admin` on stations and printers, `printing_admin` on the
other tables). Inserts, updates and deletes through table actions stay refused.
- Schema step for a host that owns the schema (PostgreSQL, one transaction):
```sql
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS device_name VARCHAR(1024);
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024);
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024);
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024);
UPDATE printing_job j SET device_name = d.name, computer_name = d.computer_name
FROM printing_device d WHERE d.id = j.device_id AND j.device_name IS NULL;
UPDATE printing_job j SET printer_name = p.name, queue_name = p.queue_name
FROM printing_printer p WHERE p.id = j.printer_row_id AND j.printer_name IS NULL;
ALTER TABLE printing_job DROP CONSTRAINT IF EXISTS printing_job_device_id_fkey;
CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL,
kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL,
computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36),
actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL,
unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL);
CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id);
```
`printing_job_device_id_fkey` is the name PostgreSQL gives the inline `REFERENCES` of the
original `CREATE TABLE`; a schema that named it otherwise drops that name. `initialize()`
does all of this itself and finds the foreign key by metadata.
- Without the step this version fails loudly: every submission is rolled back with the
database's missing-column error for `device_name`, and QQQ cannot read Print jobs. If only
the foreign key is left in place, deleting a station that has jobs is refused (409, naming
the step) before anything is revoked. The previous library keeps working on the new schema;
jobs it writes get their names when their station or printer is deleted.
### 1.2.0-RC.4: a station delete decides its refusal under the lock
No wire, contract or schema change; the schema step above is the same.
- **Fixed: deleting a station could close a job as `OUTCOME_UNKNOWN` that the station had
just been sent and was about to print.** `deleteDevice` decided "connected and printing"
from a read without a lock, revoked the station, and only then locked and read again; a
revoked station never counts as connected, so the second read could not refuse. A station
that was idle at the first read and claimed a job before the revocation was deleted with
that job in its hands.
- The delete is now one transaction. It takes the tenant admission lock and then the
station's row lock (the order the other paths use; a claim, a job event and a heartbeat
take the station's row lock before they change anything), decides the refusal there,
applies the REVOKE control on the same connection, closes the jobs, deletes the rows and
commits once. A claim either commits before the delete locks, and the delete is refused
(409, the station and its job untouched), or waits for the delete and is then answered
`404 Device not found`: the job was canceled by the delete and was never sent.
- A delete that is refused or fails changes nothing. Before, a delete whose transaction
failed left the station revoked; now the revocation rolls back with the rest and the
delete can simply be run again. A station that an earlier version left revoked, or that
was revoked with **Revoke print station access**, is deleted without a second revocation.
- The station's connection is woken once, after the commit. The revocation of a delete no
longer goes through the public `PrintingService.deviceAction`; a host that wraps that
method to observe revocations sees a delete in `printing_deletion` instead.
- `deletePrinter` did not have the window (it decides under the station's row lock and
revokes nothing) and is unchanged.
- **HTTP API text restored.** RC.3 changed one text an HTTP route returns, contrary to the
note above: approving or unapproving a removed printer (`PUT
/printing/v1/admin/devices/{deviceId}/printers/{printerId}`, 409) answers "Printer was
removed; share it from the device again before changing its approval" again. The other
text RC.3 changed ("Revoke the print station's access before removing it") is raised only
by `removeDevice`, which no HTTP or agent route calls, and stays.
- `initialize()` fills `device_name` / `printer_name` only on jobs whose station or printer
still exists. Before, jobs of a deleted station or printer that have no name were rewritten on
every start, and where such a job had a queue name but no printer name the queue name was
set to null. They are still read on every start; there is no index on the name columns.
- Deleting a station or a printer writes the names only onto its jobs that lack one, so a
long job history is not rewritten while the tenant's admission lock is held.
- Tests added for: the claim that lands just before the delete locks (station and printer)
and the claim in flight that the delete waits for; a printer refused while it prints on a
connected station; an already revoked station; a failed transaction rolled back and
retried; the foreign-key violation (`23503`) named as the missing schema step; the final
step refusing a record that passed the review; the review naming a record that went away
or conflicts; a grouped read of print jobs counting the caller's tenant only. They run on
the in-memory database of the test suite; the build has no PostgreSQL driver or container
support, so the delete and the schema step are not exercised on PostgreSQL here.
## 1.1.2
Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract
change (extension 2.4.0, wire v1, subprotocol unchanged). The server now runs on QQQ
**4.1.0-RC.22**, published only in the Gitea registry (it carries the session-logout,
process-identity and scoped-session fixes the Forge cohort depends on). CI moves from the
`qrun-release` caller and trusted publisher to the shared `ci-workflows` Maven workflow,
which resolves QQQ from the Gitea registry with the job token and publishes `vX.Y.Z` tags
straight to it. The ui-demo frontend (`qqq-frontend-next` 1.0.0-RC.37) was mirrored into the
registry byte-for-byte from Central, so every QRun artifact resolves from Gitea; third-party
dependencies still resolve through the Maven Central mirror until the registry proxies them.
Same test gate (`-Pui-demo verify`).
## 1.1.1
Realtime upgrade fix; no contract change. The WebSocket callbacks read the device
identifier from the socket context's headers, which are matched case-sensitively, while the
HTTP upgrade matches them case-insensitively. Behind an ingress that lowercases header names
the device resolved to null on connect and the connection failed before WELCOME (Forge had
carried a local patch for this on 1.0.0). The device is now resolved once on the authorized
upgrade and carried on the connection; a regression test connects with lowercase header
names and verifies WELCOME, an acknowledged inventory, the PRINTERS push and socket replacement.
## 1.1.0
Native printing extension **2.4.0** (additive; wire payload v1 and the agent
subprotocol `qqq-printing.v1` are unchanged). Pairs with protocol 1.1.0 and Windows
connector 1.2.1; 1.0.x connectors keep working unchanged.
- New HELLO and HTTP control-poll capability `printer-approval-v1` (HELLO accepts up to
five capabilities, the control poll up to four; it is stored in the device's control
capabilities like the others).
- New server-to-client realtime frame `PRINTERS`: `{ "printers": [ { "printerId", "enabled" } ] }`,
the complete approval state of every printer in the device's latest accepted inventory.
Sent only to connections that advertised the capability, after each accepted INVENTORY's
ACK and whenever a printer's approval changes through any path (admin route, native
approve/disable/set-approval processes, `PrintingService.enablePrinter`). An empty
inventory yields an empty list; other connections never receive the frame.
- `PrintingService.printerApprovals(deviceId)` exposes the same list to host code.
- Remove printers and devices without losing history. New nullable `removed_at` columns on
`printing_printer` and `printing_device` (read-only field **Removed at** on both tables, added
by the idempotent migration). **Remove printers** (`printingRemoveSelectedPrinters`, on a
printer record or a selection) and `PrintingService.removePrinter(deviceId, printerId)` set
`removed_at`, clear approval and exposure, and drop the printer from the Printers default
view (add the **Removed at is empty** filter), the operator catalog, claims and submissions,
the device/printer selectors and the `PRINTERS` frame, which is pushed again on removal. A
queue that the device shares again returns exposed but not approved. **Remove device**
(`printingRemoveDevices`, confirmation required) revokes access through the existing REVOKE
control, then `PrintingService.removeDevice(deviceId)` archives the device and all of its
printers; an already revoked device is archived without a new command, and a removed device
cannot be restored. Retention cleanup hard-deletes removed printers that no job references
and removed devices with no jobs and no printers, bounded by the run's batch size.
- Regenerated OpenAPI fragment (`printing.yaml`): semantic version 2.4.0 and the extended
`ControlPollRequest` capability enumeration. No authorization, queue, replay or document
behavior changed.
## 1.0.0
Product version 1.0.0 includes record, table-list and text print processes,
PDF rendering with headers and footers, the least-privilege operator catalog,
reported Windows queue-default media (`printer-media-v1`) and the opt-in retained-source
facade. Native printing stays **2.3.0** with wire payload v1; 1.0 is a product version, not a
wire change, and it pairs with Windows connector 1.0.1 and protocol 1.0.0. The QQQ dependency
is the published 4.1.0-RC.21 candidate, which provides native session login.
## 0.4.0-rc.1
Printing extension **2.3.0** (additive; wire payload v1 and the agent subprotocol are
unchanged). 2.1.0 added the record, table-list and text print processes; 2.2.0 added
`printingRenderToPdf`, the header and footer values on every document and content
sections; 2.3.0 adds the least-privilege operator catalog/submission/request lookup,
reported Windows queue-default media (`printer-media-v1`) and the opt-in
retained-source facade. Clients should offer a feature when its process is in the
metadata, not by comparing versions. An agent that does not advertise
`printer-media-v1` stays a three-capability peer and cannot claim a job carrying a
media selection, whatever extension version it discovers.
- Built-in test prints add 4 x 1 and 4 x 2 inch labels (203/300/600 dpi) beside the unchanged
4 x 6 path: compact PDF and ZPL layouts, and for short PDF labels an explicit actual-size
job paper (`paperWidthMm`/`paperHeightMm`, a complete positive pair up to 2000 mm, never
combined with a media profile). 4 x 6 and page profiles keep using the queue default.
- Strict source standards: Error Prone 2.51.0 and NullAway run as errors with `-Werror` on
main and test sources, with honest `@Nullable` contracts, strict source headers and
development-fixture style gates. No authorization, retained-source, replay or queue
behavior was changed by this work.
- Realtime shutdown now waits, with a bound, for channel workers that are already
running before the host's durable state is released. Previously the executor was
only told to stop, so a late drain could query a stopped file database and
recreate it empty (found as a temporary-directory cleanup error in a full test
run).
Observed locally on the merged candidate: full `-Pui-demo verify` 189 reported,
188 executed, one intentional opt-in workload skip, zero failures/errors, zero
Checkstyle violations; five consecutive focused realtime runs passed. Hosted CI on
the final signed head, publication and consumer qualification have not run; no
physical output is claimed.
- The app's header and footer on every page of every document (record sheets,
table lists, text and content pages): app name, title or breadcrumb, server,
user and printed-at at the top; the client's footer (e.g. `qqq ui 1.0.1 ·
server`), a list's filter and sort, and "Page n of m" at the bottom. Clients
send `headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`,
`footerLeft` and `footerNote`; missing ones default to the instance branding,
the session user, the current instant and "QQQ printing".
- Content documents: `printingPrintText` and `printingRenderToPdf` (`TEXT`) take
`sections` (JSON `[{title, lines}]`), a screen's panels without navigation or
borders, rendered as headings with their lines wrapped to the page width.
- Save as PDF: `printingRenderToPdf` renders the document a print would carry -
a record sheet (`kind` `RECORD`, `tableName`, `recordId`), a table list (`LIST`,
the `printingPrintTableList` values) or a text page (`TEXT`) - and returns it to
the caller as the standard process download (`downloadFileName`,
`serverFilePath`) instead of queueing a job. No printer is needed; reads enforce
the caller's permissions, and a list over the limit says so in
`printingMessage`. Clients offer it as "Save as PDF…" in their printer pickers.
- Print anything: `PrintingRecordProcesses.registerAll(instance)` adds **Print** to
every table that can be read one record at a time (call it after the host's
tables are in the instance). Hidden tables, tables without a primary key or
`TABLE_GET`, and tables carrying `PrintingTableSettings().withPrintable(false)`
are skipped.
- The record sheet now follows the table's metadata: field sections in tier order
with their labels, remaining fields under "Other fields", and a summary of each
child list (association): its label, row count and first ten rows.
- New `printingPrintTableList`: a table list (grid) of any readable table with the
caller's columns (in order) and widths, filter and sort, either the whole result
(`scope` `ALL`, the default: read a thousand rows at a time up to `maxRows`,
10,000 by default and at most; a larger result prints its first rows and says
so in the header and in `printingMessage`) or the current page (`PAGE`, the
filter's skip/limit). Letter or A4; orientation `AUTO` (landscape when the
columns need it), portrait or landscape. The header repeats on every page; the
page header carries the table, the rows printed of the total, the filter text
and the sort, and the footer the table, time, user and "Page n of m".
`printingRowCount` reports the matching rows before printing.
- New `printingPrintText`: any text (a capture of a terminal screen) as a
monospace PDF, wrapped and broken across pages.
- All documents share one page writer with "Page n of m" footers and a running
title on later pages. Table and record reads enforce the user's permissions.
## 0.3.1-SNAPSHOT
- Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a
**Print** process (`printingPrintRecords_{table}`) to a host table's records and
selections. It asks for an approved, exposed PDF printer and a copy count, shows
the standard review, then renders each selected record as a Letter record sheet
(`PrintingRecordDocuments`: heading, visible non-binary fields with display
values, wrapped and continued across pages) and queues one PDF job per record.
The queued job IDs are returned in the process value `printingJobIds`.
- Fix selection actions whose first step is a form (**Print a test page**,
**Print a test label** on selected printers, and **Print**): over the UI API the
record selection reaches only the init call, so the review answered "No records
were selected". The prepare step now keeps the selection as `queryFilterJson`.
## 0.3.0-rc.1 — 2026-10-31
Upgrade the server before the matching Windows connector. Native printing API
+15 -4
View File
@@ -1,11 +1,22 @@
# Contributing
Use Java 21 and the checked-in Maven 3.9.11 wrapper. Clone `QRun-IO/qqq-print-protocol` as a sibling and check out the protocol commit pinned in `.github/workflows/ci.yml`. See README for the Windows and Unix build commands.
Use Java 21 and the checked-in Maven 3.9.11 wrapper. Clone `QRun-IO/qqq-print-protocol` as a sibling and check out the protocol commit pinned in `.gitea/workflows/qrun-release.yaml`. See README for the Windows and Unix build commands.
Run `./mvnw verify` (`.\mvnw.cmd verify` on Windows). The validate phase enforces the repository Checkstyle configuration; tests cover real H2 persistence, native QQQ metadata/processes and the public loopback HTTP contract. Add focused coverage for changed behavior. Do not replace spooler acceptance with a physical-print assertion.
Run `bash ./mvnw -Pui-demo verify` (`.\mvnw.cmd -Pui-demo verify` on Windows), matching the primary CI profile. The validate phase enforces the repository Checkstyle configuration; tests cover real H2 persistence, native QQQ metadata/processes and the public loopback HTTP contract. Add focused coverage for changed behavior. Do not replace spooler acceptance with a physical-print assertion.
Changes belong in a pull request with a concise behavior description and actual validation results. Update README, changelog, relevant tests and shared contract together when changing the wire interface. Use Conventional Commit subjects. Do not include local databases, logs, device credentials, customer documents or machine-specific printer information.
Changes belong in a pull request on [the primary Gitea repository](https://git.kof22.com/QRun-IO/qbit-printing) with a concise behavior description and actual validation results. Update README, changelog, relevant tests and shared contract together when changing the wire interface. Use Conventional Commit subjects. Do not include local databases, logs, device credentials, customer documents or machine-specific printer information.
Keep the QBit compatible with QQQ 4.1+ and resolve runtime services through the current QQQ instance. Preserve tenant/device isolation and the distinction between local exposure (`available`) and server approval (`enabled`). Application callers must authorize destinations before submitting or emitting an effect.
Maintainers create reviewed version tags. CI produces JAR/POM/checksum assets and publishes GitHub prereleases for hyphenated versions. Maven Central publication, deployment and physical printer tests are separate operations; this workflow does not perform them.
Maintainers publish reviewed versions through the primary Gitea CI and trusted Maven publisher, then attach the verified JAR/POM/checksums to a Gitea release. Follow [release operations](docs/RELEASES.md). GitHub is a shadow/archive; its Actions are retired for this repository. Historical `.github` scripts remain available as local quality checks. Deployment and physical printer acceptance are separate from library publication.
## Script quality
Owned `.github/scripts/*.py` follow the KofTwentyTwo standard at revision `fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152` (config adapted from its `python` profile): three-space indentation, Ruff 0.17.3 format/lint (locked in `uv.lock`) and strict Pyright 1.1.414 (Microsoft's npm package, a development-only dependency locked in `package-lock.json` and run through Node 24). Restore with `uv sync --locked` and `npm ci --ignore-scripts`, then run:
```text
uv run --locked --no-sync python .github/scripts/test-script-quality.py
uv run --locked --no-sync python .github/scripts/check-script-quality.py
```
`check-script-quality.py` fails for any tracked Python outside `.github/scripts/` or any JavaScript/TypeScript (this repository has no tsconfig), and requires Pyright's analyzed-file count to equal the selected scripts. Pyright must run as `pyright -p .github`: it silently skips dot directories from a root config. Documented exceptions: CLI `print` only in `check-*.py`; synthetic test assertions and boundary constants; narrow inline `noqa` comments for fixed no-shell Git/interpreter calls and ordered fail-closed validators. Do not loosen a rule or add a type waiver to pass.
+47 -27
View File
@@ -1,12 +1,14 @@
# QQQ Printing
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent) and the [shared protocol](https://github.com/QRun-IO/qqq-print-protocol). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Release candidate **0.3.0-rc.1** requires native printing API **2.0**, with no job/control polling fallback. Upgrade the server before the matching Windows connector.
Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://git.kof22.com/QRun-IO/qqq-print-agent) 1.2.1 and the [shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol) 1.1.0 (1.0.1 / 1.0.0 remain compatible). The Windows connector opens an outbound, authenticated two-way WebSocket; documents download separately over HTTPS. The cloud needs no inbound route into the facility. Version **1.1.0** supports native printing **2.4**, wire payload v1, real-time jobs and controls, native browser sign-in through QQQ, and pushes each exposed printer's approval to connectors that advertise `printer-approval-v1`. Upgrade the server before the matching Windows connector.
**Prerelease software**, tested against QQQ **4.1.0-RC.20** and Java 21. Current release candidate: [**0.3.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1). The previous [**0.2.0-rc.1**](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.2.0-rc.1) tagged build passed 110 tests and zero Checkstyle violations. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. GitHub releases provide a library JAR, POM and SHA-256 checksums, with build attestations for all three assets. Verify the exact downloaded release; publication does not establish installed or physical-output acceptance. This repository does not claim Maven Central publication or provide a standalone executable/fat JAR.
**Released:** [QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2), with a library JAR, matching POM and `SHA256SUMS`, built on QQQ 4.1.0-RC.22 from the Gitea registry ([1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) is the same code on RC.21 from Maven Central; [1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) is superseded by the realtime upgrade fix; [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) remains available for rollback). The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
**Runtime:** Java 21 and QQQ **4.1.0-RC.22**, published in the QRun-IO Gitea Maven registry (not on Maven Central) with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**, also served from that registry. Product version 1.1.2, native extension 2.4.0 and wire payload v1 are separate version contracts.
Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target.
The 0.2.0-rc.1 source adds native record/selection actions, per-device printers/jobs/queue/history, desired versus observed status and standard-session authentication at the discovered `/qqq/v1/qbit/printing` extension. Device credentials become secondary identity proof alongside the current QQQ user's permissions. Upgrade the server before the 0.2.0 Windows client, then explicitly associate an existing unbound pairing during sign-in. These changes are included in the published release above. See the [authentication contract](https://github.com/QRun-IO/qqq-print-protocol/blob/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication.
Native record/selection actions expose per-device printers, jobs, queue and history, with desired versus observed status. The discovered `/qqq/v1/qbit/printing` extension uses standard QQQ session authentication. Device credentials are secondary identity proof alongside the current QQQ user's permissions. Existing unbound pairings require explicit association during sign-in. See the [authentication contract](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/docs/QQQ-AUTHENTICATION.md); bound devices cannot fall back to legacy `/printing/v1` authentication.
```mermaid
flowchart LR
@@ -19,16 +21,22 @@ flowchart LR
See the [real-time host integration](docs/REALTIME.md), [screen walkthrough](docs/UI-WALKTHROUGH.md), [storage and retention](docs/STORAGE-AND-RETENTION.md), [operations and troubleshooting](docs/OPERATIONS.md), [contributing](CONTRIBUTING.md), [security reporting](SECURITY.md), [changelog](CHANGELOG.md), and [Apache-2.0 license](LICENSE).
## Local development
Start an isolated native QQQ application, MinIO and optional Authentik with the
[Docker development commands](dev/README.md). The default test runs synthetic
Java/.NET WebSocket jobs and S3 checks without an installed connector or printer.
## Build and verify
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings. This source pins protocol commit `f3cbc02e9b0fb4fdfefaa59d976c04df747cc589`; use that commit for reproducible compatibility tests. Use the checked-in wrapper (Maven 3.9.11).
Install Java 21. Clone this repository and `qqq-print-protocol` as siblings from Gitea. Check out protocol commit `5005f3c9e27053b6c327bb923695012832c0fdcc`, the immutable fixture ref in `.gitea/workflows/qrun-release.yaml`. Use the checked-in wrapper (Maven 3.9.11).
```powershell
.\mvnw.cmd verify
.\mvnw.cmd -Pui-demo verify
.\mvnw.cmd dependency:build-classpath '-Dmdep.outputFile=target/runtime-classpath.txt' '-DincludeScope=runtime'
```
On Linux/macOS use `bash ./mvnw verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached.
On Linux/macOS use `bash ./mvnw -Pui-demo verify` and the corresponding wrapper commands. First builds download dependencies; `-o` is only appropriate after they are cached.
Checkstyle runs during validate. Tests exercise real H2 persistence, native QQQ metadata/processes and real loopback HTTP through Javalin, including the shared fixtures and withdrawal of a locally exposed printer. Keep `../qqq-print-protocol` checked out or pass `-Dprinting.protocol.fixtures=<fixture-directory>`.
@@ -64,14 +72,14 @@ Open `http://127.0.0.1:18082`. On Linux/macOS use `:` as the classpath separator
The protocol-only `PrintingDemo` and synthetic, mock-authenticated `PrintingUiDemo` remain separate. Do not point the synthetic demo at real paired printer data. The local host retains database document storage until an actual dedicated S3 binding is configured in a deployment-owned application; it does not claim a remote cloud deployment.
Local native deployment is accepted with authentication and access-denial checks, preserved pairing/history, connector reconnection and 365-day retention. The previously accepted 0.1.4-rc.1 release passed 73 tests and zero Checkstyle violations; the user confirmed all three Query diagnostic outputs good. Production S3/IAM and remote deployment remain separate qualification work. The 0.2.0 controls/authentication release passed 25 real client/native QQQ interoperability checks; the user later confirmed installed Letter PDF and Zebra ZPL output, the Connected footer and both printers. These results do not qualify installed or physical behavior for 0.3.
The local setup uses the same native authorization and transport paths as an integrated application. Production authentication mappings, S3/IAM and database operation belong to the deploying host; the local fixture does not certify those settings. Dated test evidence is retained in [qualification notes](docs/QUALIFICATION-2026-11-01.md).
## Add to a QQQ application
For a source checkout, `mvnw.cmd install` (or `bash ./mvnw install`) installs the verified library into your local Maven repository. For a GitHub release, download the JAR, matching POM and `SHA256SUMS`, verify the checksums, then install those artifacts locally or through your organization's artifact repository. Example using the release files in the current directory:
Use the [authenticated Gitea Maven registry](docs/RELEASES.md#use-the-maven-registry), or download the release JAR, matching POM and `SHA256SUMS` and verify both files. For a local installation of the downloaded artifacts:
```powershell
.\mvnw.cmd install:install-file '-Dfile=qbit-printing-0.3.0-rc.1.jar' '-DpomFile=qbit-printing-0.3.0-rc.1.pom'
.\mvnw.cmd install:install-file '-Dfile=qbit-printing-1.1.2.jar' '-DpomFile=qbit-printing-1.1.2.pom'
```
After the artifact is available in your configured repository, add:
@@ -80,7 +88,7 @@ After the artifact is available in your configured repository, add:
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>0.3.0-rc.1</version>
<version>1.1.2</version>
</dependency>
```
@@ -90,6 +98,8 @@ Device and printer names, descriptions and locations are edited in the Windows c
`initialize()` adds profile columns, backfills physical names, widens existing name/title columns and creates dispatch/history indexes without deleting records. The existing `name` column becomes the effective display label; `computer_name`/`queue_name` retain technical names and `display_name` holds the optional override. Back up the database and run schema initialization during a maintenance/deployment window; column widening and index creation can lock populated tables.
Print jobs carry their creation instant twice: `created_at` is the ISO-8601 text the queue, history cursors and job API use, and `created_time` is the same instant as a UTC `TIMESTAMP` (no time zone, the QQQ convention), exposed as the date-time field **Created** so native filters ("created in the last day"), sorting and date buckets work. `initialize()` adds the column, backfills existing rows from `created_at` and creates `printing_job_created_time_idx (tenant_id, created_time, id)`. A host that owns the schema must add the column before deploying this version (see the [changelog](CHANGELOG.md) for the statements): without it every submission fails with the database's missing-column error and stores nothing, and the Print jobs table cannot be read.
Job history uses `GET /printing/v1/admin/jobs?limit=200&before=<nextCursor>` with opaque keyset cursors and a maximum 200 rows per page. Claims read one candidate, pending inspection reads 50, and all these queries project metadata without fetching stored documents. Native dashboard lists already cap attention rows at 10 per category and recent work at 25. Dispatch/history indexes support growing histories; the synthetic 10,000-row regression test checks bounded pages and claim correctness, **not** a certified 10,000-print/day throughput measurement. Driver speed, document size, database hardware and retention policy still determine capacity. Document cleanup follows the configured retention policy and host schedule; the audit does not run a live purge or delete job history.
```java
@@ -100,26 +110,30 @@ new PrintingQBitProducer().withConfig(new PrintingQBitConfig()
.withStorageTableName("printing_documents") // existing dedicated S3 storage table
.withSchedulerName("appScheduler") // existing host scheduler
.withService(printing)).produce(qInstance, "printing");
server.withAdditionalRouteProviders(List.of(new PrintingRoutes(printing, context -> hostPrintingAdminGuard(context))));
server.withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(printing)));
```
`hostPrintingAdminGuard` is the host's own authenticated administrator policy, not a supplied library method. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them.
The producer registers the native printing API; the additional provider supplies its WebSocket transport. Preserve any other host route providers and close the printing provider during shutdown. The same service instance must be used for metadata and transport. The service is bound to one tenant; use a separate host service instance for each tenant. Device token verification and job ownership are enforced on every device operation. Management service methods are intended for trusted application code; enforce user/facility authorization before calling them. See [real-time integration](docs/REALTIME.md).
The storage/scheduler options above are included in 0.1.4-rc.1. Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment.
Configure a dedicated private printing bucket for the customer/environment through existing QQQ S3 metadata; no bucket credentials belong in printing forms. Admin retention defaults to 365 days after successful completion. Scheduled cleanup handles bounded batches every five minutes and preserves active, failed and uncertain work plus all receipts/history. Without a configured storage binding, legacy database documents remain supported; without a host scheduler, automatic cleanup does not run. Review [storage configuration, bucket isolation and S3 versioning](docs/STORAGE-AND-RETENTION.md) before deployment.
Enrollment uses a named facility selector. Set `withFacilityPossibleValueSourceName("your_existing_facility_source")` to reuse an already registered host source and its record security. Without it, the standalone choice is **Default facility**. The backend validates the selection under the current user's permissions before issuing an enrollment code; typing or forging an unknown/foreign-tenant ID cannot bypass that check. Existing stored facility IDs are preserved; configure their original source to resolve their names.
For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID. The Printing home shows device health, queue status and work needing attention, with Devices, Printers and Print jobs beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead.
For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID; grant `printing_delete` as well to whoever may delete print stations and printers. A **print station** is the computer that runs the print client (table `printing_device`); a **printer** is a Windows queue it shares. The Printing home shows print station health, queue status and work needing attention, with Print stations, Printers, Print jobs, Print station control history and Deletion history beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead.
Typical setup: run **Enroll a print device** from Printing home or Print devices, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named device and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access.
Typical setup: run **Enroll a print station** from Printing home or Print stations, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named print station and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access.
For label printers, configure the machine's **Windows Printing Defaults** for the actual stock size and print method before testing PDFs through the service. A test profile selects document size and ZPL resolution; it does not change the Windows driver or printer's media configuration. Our 300 dpi Zebra required 4 × 6-inch stock and Thermal Direct instead of its previous 4.09 × 2-inch Thermal Transfer defaults. After correcting those settings and resuming the printer, the user confirmed the built-in PDF label, alongside the already confirmed ZPL label and laser PDF. Barcode scanning and measured alignment remain separate checks; `SPOOLED` alone is not proof of physical output.
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices.
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the printer and print station names they were submitted under (stored on the job, so they read the same after a rename or a delete); approval, availability, format and status fields have named choices.
Deleting is separate from removing. **Delete printers** and **Delete print stations** (on a selection or a record; they need `printing_delete` as well as `printing_admin`) take the record out of every list for good. A station is revoked first, so its computer must be enrolled again and then is a new station; its printers go with it. Queued jobs are canceled, jobs already sent without a reported result are closed as outcome unknown, and finished jobs stay in Print jobs with the names they were printed under. A record is refused, by name, while its station is connected and printing. A printer that its station still shares comes back from the next inventory as a new printer awaiting approval. Every deletion is recorded in **Deletion history** (who, what, when, what went with it). See the [walkthrough](docs/UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer).
Removing is archiving, not deleting. **Remove printers** sets **Removed at** on the selected printers, clears their approval and exposure, and takes them out of the operator catalog, claims, the `PRINTERS` frame and every device/printer selector; their rows and job history stay. The Printers and Print stations tables have no server-side default filter, so add the filter **Removed at is empty** to the view you use day to day. If the device shares a removed queue again, the next inventory brings it back exposed but **not approved**, exactly like a newly discovered queue. **Remove print station** (an action that requires confirmation) revokes the station's access through the existing REVOKE control, then archives the station and all of its printers; an already revoked station is archived without a new command. A removed station cannot be restored; connect the computer again with a fresh enrollment, which creates a new station. A removed record can be deleted. Retention cleanup later hard-deletes removed printers and devices that no job references (see [storage and retention](docs/STORAGE-AND-RETENTION.md)).
Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build.
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. A connector whose HELLO advertises `printer-approval-v1` (extension 2.4.0, connector 1.2.1) receives a `PRINTERS` frame listing `{ printerId, enabled }` for every queue in its latest accepted inventory after each accepted inventory and whenever an approval changes through any path; older connectors never receive it and keep reading the operator catalog. Removing a queue from local selection makes it unavailable without deleting its approval/history; removing a printer in QQQ additionally drops it from the frame until the device shares it again. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default decoded limits are 100 MiB for PDF and 20 MiB for ZPL, both configurable in the native Printing connection and limits process. Queue approval and format compatibility are checked before insertion.
@@ -135,31 +149,37 @@ JSONObject job = printing.submit(new JSONObject()
String printJobId = job.getString("jobId");
```
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://github.com/QRun-IO/qqq-print-protocol/blob/f977bd01b62ceb7539190fd2861809537fcf504c/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`. `printingRenderToPdf` (extension 2.2.0) renders the same documents to a PDF download for the caller instead ("Save as PDF"), and every document carries the app header and footer the client sends (`headerApp`, `headerTitle`, `headerServer`, `headerUser`, `headerPrintedAt`, `footerLeft`, `footerNote`).
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTERNAL` target in `DurableJobsMetaData`, then emit the submit-request payload. The target replaces the payload idempotency key with the durable effect key. Its SENT result means accepted into the queue, not physical output. This implementation uses its own JDBC transaction; do not register it as LOCAL_TX. Authorize the destination in the emitting process before writing the effect. QQQ's durable dispatcher runs with a `QSystemUserSession` and does not propagate the user's tenant keys. For that trusted outbox path only, the target resolves the service configured on the dispatcher's current QQQ instance; that service fixes the tenant and rejects devices belonging to another tenant. The payload cannot choose a service or tenant. A cached target resolves the current instance on every call. Inline user calls retain the `printing_tenant` scope check, and native management processes (including enrollment) still require both administrator permission and matching tenant scope. Do not expose direct effect emission through an arbitrary user-supplied target/payload endpoint.
## Deliberate first-version limits
## Deployment boundaries
The implemented path targets an explicit device/printer. Logical destination bindings, cancellation, reprint workflow, automatic reconciliation, clustered throughput optimization, retention and alerts are follow-ups. Documents are durably stored as base64 in the database for this first path; object-storage integration is deferred. Custom paper dimensions are rejected; configure the Windows queue. Received/submitting jobs are not automatically offered again. The queue records SPOOLED honestly and does not assert physical delivery.
Jobs target an explicit device/printer. Logical destination routing and a distributed real-time bus belong to the integrating host. The release includes cancellation of queued jobs, explicit reprints, owned spool-cancellation commands, durable receipt reconciliation, S3 delivery storage and scheduled retention. Received/submitting jobs are not automatically offered again; `SPOOLED` never asserts physical delivery.
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application and its real authentication/facility policy remains a host integration task. PostgreSQL behavior, production deployment, upgrades, cancellation/reprint and broad printer-driver coverage are not certified by the initial H2/laser tests.
Job paper (`paperWidthMm` and `paperHeightMm`, both positive and at most 2000, never combined with `mediaProfile`) is supported by the matching Windows 1.0.1 connector. The built-in 4 × 1 and 4 × 2 PDF tests use 101.6 × 25.4 / 50.8 mm. Without explicit paper or a reported media selection, jobs follow the Windows queue default. See [media selection](docs/OPERATOR-CATALOG.md).
## Verified initial path
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application requires that host's real authentication, facility policy, database, storage and supervision. PostgreSQL and multiple QQQ nodes are not qualified by the local H2/MinIO fixture.
On 2026-10-29, both the development Windows connector and the installed LocalSystem service printed a PDF to a local laser printer. The user confirmed both physical pages. MSI installation/start and temporary uninstall passed. Zebra hardware testing is deferred until a Zebra is connected. A SPOOLED status alone is never used as proof of physical output.
## Physical output
The user confirmed physical laser PDF and Zebra ZPL/PDF output during development, including a 4 × 1 test. A configured stock profile does not prove a physical result on another printer or stock; inspect the first output after changing media. Historical acceptance records remain in [session history](docs/SESSION-STATE.md).
## CI and releases
Pull requests and `main` pushes run Java 21/Maven wrapper verification with the immutable sibling protocol checkout. A tag matching the POM version runs build/tests and the secrets, workflow, dependency and malicious-package security gates before publishing the tested JAR, POM and `SHA256SUMS` as GitHub release assets. Build verification enables the `ui-demo` profile used by the local host. Maven generates its selected compile/runtime graph as a CycloneDX SBOM, including frontend assets. Trivy and OSV scan that graph; a guard rejects missing or different package versions, missing frontend assets, and empty inventories. CI retains the resolved graph and scanner evidence. OSV uses `--all-packages`; its pinned 2.6.0 reporter [omits Maven groups](https://github.com/google/osv-scanner/issues/3099), so the guard checks every reported artifact/version and rejects ambiguous names while the verified input retains full package URLs. Hyphenated versions create prereleases. The packaged JAR includes the repository LICENSE and NOTICE under `META-INF`, alongside the existing font license and diagnostic assets. The workflow does not publish to Maven Central, deploy a QQQ application or print to hardware. The [0.1.0-rc.1 release](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.1.0-rc.1) passed these hosted gates.
Gitea is the source, pull-request, CI and release authority. The thin `.gitea/workflows/qrun-release.yaml` delegates to the protected `qrun-release-v1` channel with Java 21, the Maven wrapper, `ui-demo` and the immutable protocol fixture checkout. Required verify, secrets and gate checks precede merge; successful main builds capture the tested Maven reactor for the trusted registry publisher. Maintainers publish the corresponding tagged Gitea release from those verified bytes. See [release operations](docs/RELEASES.md#maintainer-release-path).
GitHub is a shadow/archive. Its Actions were retired for this repository on 2026-11-04; checked-in `.github` workflows describe the former pipeline and do not publish current printing releases. The separate Windows repository retains its signing/update-feed workflow. The 1.0.0 and 1.1.0 server releases do not claim GitHub attestations or Maven Central publication. The JAR includes LICENSE, NOTICE and font/diagnostic asset licenses; publication does not deploy a QQQ host or print to hardware.
## Printing home
The admin-only Printing home uses stock QQQ widgets: a purpose/scope/help header, actionable exceptions, four health tiles, and a full-width pending/recent work list. Counts and record links use QQQ queries under the viewer's tenant. Device disconnection means no heartbeat for 90 seconds; job failure tiles cover jobs created in the past 24 hours. Older failures remain in Print jobs. An empty installation explains enrollment and printer setup.
This RC implements the happy path of the [QQQ dashboard standard](https://github.com/QRun-IO/qqq/blob/main/docs/DASHBOARD-STANDARD.md), with explicit remaining gaps: RC.20 carries role/priority hints in widget default values; attention uses scoped database queries and 60-second refresh rather than the core live attention feed; dismiss/snooze, interactive URL-backed page scope, and stale/retry presentation depend on further core/frontend integration. It has not passed the standard's full responsive/accessibility audit. No custom frontend or HTML dashboard is used.
The home uses the standard QQQ frontend widgets and scoped queries. Its timed dashboard refresh updates the visible summary; job delivery uses the independent real-time WebSocket and does not wait for a dashboard refresh. No custom frontend or HTML dashboard is required.
### Operator actions (0.1.1-rc.1)
### Operator actions
The Windows companion can request test prints and explicit reprints through device-scoped authenticated routes. The same printer approval/exposure rules apply. Upgrade the QBit and connector together for these additive protocol features. Reprints create a new job; uncertain/spooled originals require duplicate-output consent and remain unchanged.
@@ -169,6 +189,6 @@ QQQ's **Print jobs** table and records expose **Print again** and **Cancel queue
All built-in printer test actions share `PrintingTestDocuments`: QQQ branding, the official [Query dog mascot](https://qrun.io/query), large warehouse-style identifiers and embedded JetBrains Mono Bold with a dotted zero and distinct 1/I/l. Query's original artwork and aspect ratio are preserved in PDF and thermal output; [asset provenance](src/main/resources/com/kingsrook/qbits/printing/images/README.md) ships with the resource. The font is included under SIL OFL 1.1; its license and provenance ship in the JAR. PDF embeds a font subset. ZPL renders the same glyphs into document-local, row-aligned graphics below Zebra's 99,999-byte per-field limit; it does not download or save a printer font.
Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles are 4 × 6 inches at 203, 300 or 600 dpi; PDF label output is always 101.6 × 152.4 mm. Select matching stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not reconfigure Windows stock, calibrate the printer or save device configuration; ZPL width/length are transient format settings.
Native record and selected-table actions expose a possible-value stock/resolution selector. Letter and A4 are PDF-only. Label profiles cover 4 × 1, 4 × 2 and 4 × 6 inches at 203, 300 or 600 dpi, with corresponding PDF dimensions of 101.6 × 25.4, 50.8 or 152.4 mm. Select matching loaded stock and printer resolution. Bulk actions apply the chosen profile to every selected destination, so group compatible printers. The document does not change the Windows queue defaults, calibrate the printer or save device configuration; ZPL width/length are transient format settings. Direct-thermal stock needs the driver's Direct Thermal setting, without a ribbon.
The device test-print API accepts optional `testProfile` with the same choices. Existing clients default to Letter PDF or 203-dpi 4 × 6 ZPL. Invalid profiles fail before enqueueing. Test actions use the ordinary durable queue and existing printer exposure/approval rules; discovery never automatically prints. A ZPL barcode, glyph comparisons and size/alignment marks support physical inspection. Rendering previews and spool acceptance do not prove paper or scanner quality.
+3 -3
View File
@@ -1,9 +1,9 @@
# Security
Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues.
Report vulnerabilities privately through [GitHub vulnerability reporting](https://github.com/QRun-IO/qbit-printing/security/advisories/new), or `contact@kingsrook.com` if that channel is unavailable. Include affected versions, a minimal synthetic reproduction and expected versus actual authorization behavior. Ordinary source changes and pull requests belong in Gitea. Do not put enrollment codes, bearer credentials, customer documents or exploit details in public issues.
The current prerelease targets QQQ 4.1+. Fixes target the current prerelease; older QQQ versions are outside scope.
The supported release line is 1.0.x for QQQ 4.1+, currently built against QQQ 4.1.0-RC.21. Report the exact QBit, QQQ and Windows connector versions. Older QQQ versions are outside scope.
The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Device credentials only authorize their device's queue/content/status operations. HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback.
The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Native device operations require both the current QQQ user session and bound-device proof; a device token alone cannot authorize them. Operator catalog/submission uses `printing_operator` or administration plus native destination/process permissions; it grants no general device administration. See [authorization and controls](docs/OPERATIONS.md#device-controls-and-durable-action-receipts). HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback.
The UI demonstration uses a fixed mock identity and synthetic data and must remain local. It registers no connector transport routes. RAW ZPL is printer-control input: submit trusted generated labels, not arbitrary untrusted printer commands. Stored documents can contain personal information. See [storage and retention](docs/STORAGE-AND-RETENTION.md) for dedicated bucket boundaries, tenant isolation and the default 365-day policy.
+206
View File
@@ -0,0 +1,206 @@
# KofTwentyTwo .NET code style (standards: K22-CODE-CS): the Kingsrook code style
# (https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) applied to C#.
# Copy to the repository root. Severities of "warning" fail the build because the
# companion Directory.Build.props sets TreatWarningsAsErrors and EnforceCodeStyleInBuild,
# so local `dotnet build` and CI enforce exactly the same rules. The resharper_* keys
# apply the Kingsrook blank-line rules in Rider and ReSharper, which .NET's own
# formatter cannot express.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
# Kingsrook: always 3 spaces, never tabs.
indent_size = 3
tab_width = 3
# YAML stays at 2: the YAML ecosystem (GitHub Actions, Dependabot) assumes it.
[*.{yml,yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
# Windows-only script formats require CRLF.
[*.{cmd,bat}]
end_of_line = crlf
[*.cs]
# All fixture sources are repository-owned Apache-2.0 code.
file_header_template = Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0
dotnet_diagnostic.IDE0073.severity = warning
#### Formatting (IDE0055 makes every rule below a build error) ####
dotnet_diagnostic.IDE0055.severity = warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = one_less_than_current
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_space_after_cast = false
# Kingsrook: no space between a control keyword and its parenthesis: if(x), for(...), catch(...).
csharp_space_after_keywords_in_control_flow_statements = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_after_comma = true
csharp_space_before_comma = false
csharp_space_after_semicolon_in_for_statement = true
csharp_preserve_single_line_statements = false
csharp_preserve_single_line_blocks = false
#### Kingsrook blank lines and layout (Rider / ReSharper; .NET formatter cannot express) ####
resharper_blank_lines_around_method = 3
resharper_blank_lines_around_property = 3
resharper_blank_lines_around_auto_property = 1
resharper_blank_lines_around_type = 3
resharper_blank_lines_after_using_list = 2
resharper_keep_blank_lines_in_code = 1
resharper_keep_blank_lines_in_declarations = 3
resharper_brace_style = next_line
resharper_indent_size = 3
resharper_space_after_keywords_in_control_flow_statements = false
resharper_max_line_length = 999
resharper_wrap_before_first_method_call = true
resharper_wrap_chained_method_calls = chop_if_long
#### Usings and namespaces ####
# IDE0005 needs GenerateDocumentationFile=true to run during build (set in Directory.Build.props).
dotnet_diagnostic.IDE0005.severity = warning
csharp_using_directive_placement = outside_namespace:warning
csharp_style_namespace_declarations = file_scoped:warning
dotnet_diagnostic.IDE0161.severity = warning
#### var: only when the type is apparent from the right-hand side ####
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_var_elsewhere = false:warning
dotnet_diagnostic.IDE0007.severity = warning
dotnet_diagnostic.IDE0008.severity = warning
#### Braces, modifiers, readonly ####
csharp_prefer_braces = true:warning
dotnet_diagnostic.IDE0011.severity = warning
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
dotnet_diagnostic.IDE0040.severity = warning
dotnet_style_readonly_field = true:warning
dotnet_diagnostic.IDE0044.severity = warning
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
dotnet_diagnostic.IDE0036.severity = warning
#### this. qualification: never ####
dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_property = false:warning
dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning
dotnet_diagnostic.IDE0003.severity = warning
#### Language keywords over framework type names ####
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
dotnet_diagnostic.IDE0049.severity = warning
#### Dead code and redundancy ####
dotnet_diagnostic.IDE0051.severity = warning # unused private member
dotnet_diagnostic.IDE0052.severity = warning # unread private member
dotnet_diagnostic.IDE0059.severity = warning # unnecessary assignment
dotnet_diagnostic.IDE0060.severity = warning # unused parameter
dotnet_diagnostic.IDE0035.severity = warning # unreachable code
dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast
dotnet_diagnostic.IDE0100.severity = warning # unnecessary equality operator
dotnet_diagnostic.IDE0080.severity = warning # unnecessary suppression operator
dotnet_diagnostic.IDE0079.severity = warning # unnecessary suppression
dotnet_code_quality_unused_parameters = all
#### Modern language features (suggestions: shown in IDE, not enforced) ####
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
#### Naming ####
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_begin_with_i.style = i_prefix_pascal_case
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.i_prefix_pascal_case.required_prefix = I
dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case
dotnet_naming_rule.type_parameters_begin_with_t.symbols = type_parameters
dotnet_naming_rule.type_parameters_begin_with_t.style = t_prefix_pascal_case
dotnet_naming_rule.type_parameters_begin_with_t.severity = warning
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
dotnet_naming_style.t_prefix_pascal_case.required_prefix = T
dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case
dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members
dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case
dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
dotnet_naming_symbols.types_and_members.applicable_kinds = namespace, class, struct, enum, delegate, property, method, event
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields
dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel_case
dotnet_naming_rule.private_fields_are_underscore_camel.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_rule.private_static_fields_are_s_camel.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_are_s_camel.style = s_camel_case
dotnet_naming_rule.private_static_fields_are_s_camel.severity = warning
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_style.s_camel_case.required_prefix = s_
dotnet_naming_style.s_camel_case.capitalization = camel_case
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
dotnet_naming_rule.constants_are_pascal_case.severity = warning
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
#### Analyzer adjustments (each one justified) ####
# CA1716: flags members named like VB keywords (e.g. ILog.Error). KofTwentyTwo is C#-only.
dotnet_diagnostic.CA1716.severity = none
# MA0004 (ConfigureAwait): app code runs on UI threads where ConfigureAwait(false) is
# wrong by default; libraries opt back in with a [*.cs] override in their own folder.
dotnet_diagnostic.MA0004.severity = none
# MA0049 (type name matches namespace): conflicts with the AppName/AppName.Core layout.
dotnet_diagnostic.MA0049.severity = none
[**/{Generated,obj}/**.cs]
generated_code = true
[{tests,test}/**.cs]
# xUnit's Method_Scenario_Expectation names use underscores by convention.
dotnet_diagnostic.CA1707.severity = none
# Inline arrays keep test data at the assertion; each test runs once.
dotnet_diagnostic.CA1861.severity = none
# Test projects are not documented API.
dotnet_diagnostic.CS1591.severity = none
# MA0048 (one type per file): a test file groups the test classes and fakes of one feature.
dotnet_diagnostic.MA0048.severity = none
+5
View File
@@ -0,0 +1,5 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Keep the SDK formatter identical on Windows and Linux checkouts.
*.cs text eol=lf
+127
View File
@@ -0,0 +1,127 @@
# Local Authentik reference
The released application uses QQQ 4.1.0-RC.21, including the native session-login
grant. A production host configures QQQ's standard OAuth2/Authentik provider,
trusted HTTPS, redirect registration and current user/tenant permissions. The
Windows connector discovers that setup through the QQQ UI/UX API; provider
refresh credentials remain on the server. See [the native authentication
contract](https://git.kof22.com/QRun-IO/qqq-print-protocol/src/branch/main/docs/QQQ-AUTHENTICATION.md).
## Historical reference fixture compatibility
The optional `Invoke-Dev.ps1 -Action Test -Authentik` diagnostic below was written
for RC.20. `Invoke-Authentik.ps1` explicitly expects API 1.8 without
`nativeSessionLogin`, so that diagnostic is not compatible with the current RC.21
application baseline. Do not interpret its expected-absence assertion as a
production native-authentication failure or use it as a 1.0 release gate. The
separately pinned experimental profile remains available for reproducing its
recorded result. No fixture code or pins were changed during documentation closeout.
The following describes the retained fixture and its recorded scope. It requires
Docker Compose, PowerShell 7.4+ and Linux/amd64 container support.
The command creates a fresh, owned development project, runs the normal QQQ and
MinIO smoke test plus the Authentik reference checks, then removes that project's
containers and volumes. It does not operate the installed Windows connector,
existing Docker stacks or physical printers. It needs no cloud account.
For an environment that remains available for development, use `-Action Start`
with an explicit `-StateDirectory` outside the checkout. Use that same directory
with `-Action Test -Authentik`, then `-Action Stop` when finished. Stop destroys
only that owned project's synthetic databases and objects; retained private
reports are separate. See the [development command](Invoke-Dev.ps1) for port
overrides. Authentik defaults to `http://127.0.0.1:19010`;
its database port is not published. The QQQ host remains on standard TABLE
authentication.
## What the automated check establishes
- Real Authentik 2026.8.5 starts with a separate PostgreSQL 16 Alpine database.
- A synthetic confidential OAuth provider/application and development user are
configured through Authentik's API. The callback list is explicit and grants
are limited to authorization code and refresh. Access/refresh validity is
two/ten minutes for a future bounded renewal test.
- Actual provider discovery has the expected issuer, same-origin endpoints and
S256 capability. A valid state/S256 request with the registered callback
continues to the provider login flow without following it. Changing only that
callback to an unregistered redirect is rejected before any user login.
- The historical QQQ RC.20 host advertised UI API 1.8 without `nativeSessionLogin`.
That expected absence is recorded as a native-client prerequisite failure,
not silently replaced by bearer authentication or an unpublished core.
`auth-result.json` distinguishes those checks from browser login, renewal and
the Windows callback, which are **not run**. Provider setup/discovery is not an
OIDC user-session integration test. No password login dialog is automated.
## Credentials and ownership
The common entrypoint creates a protected state directory outside the checkout.
`Invoke-Authentik.ps1 -Action Initialize` appends only its `AUTHENTIK_*` settings
to `compose.env`, preserving the QQQ/MinIO settings. It records synthetic
credentials in private `auth-secrets.json`; neither file belongs in Git, a bug
report or terminal output. Bootstrap uses a Django password hash. The temporary
bootstrap API token remains confined to this disposable project and disappears
with its database at cleanup. Do not reuse these accounts outside the fixture.
The Docker administrator can inspect container configuration. The scripts do not
print credentials or provider error bodies, follow redirects with an API token,
or request raw container logs. HTTP reads have a 15-second cancellation deadline
and a 512 KiB response limit. Startup has a bounded readiness loop. Initializer
replays retain credentials; conflicting or partial state is refused.
Compose pins cached/public image digests, not floating tags:
<!-- markdownlint-disable MD013 -->
| Component | Image digest |
| --- | --- |
| Authentik server/worker 2026.8.5 | `ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5` |
| PostgreSQL 16 Alpine | `postgres@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea` |
<!-- markdownlint-enable MD013 -->
There is no Redis requirement for this Authentik version, Docker socket mount,
outpost management, shared volume or automatic restart policy. Container and
volume labels identify the exact generated Compose project. The common cleanup
checks those labels before removing resources; it never performs a global prune.
## Separate native-session qualification
The Windows native path requires the centrally defined API 1.9 typed
`AUTHORIZATION_CODE_PKCE_SESSION` grant and trusted HTTPS on both QQQ and provider
origins. RC.20 does not supply that grant, and this HTTP development profile does
not weaken the client's HTTPS rules. The synthetic reference callback is only a
provider configuration value; no callback receiver or QQQ OAuth adapter is
claimed here. The TABLE host and installed pairing are unchanged.
The separate [experimental native profile](native-auth/README.md) builds an exact
public, unreleased QQQ candidate inside Docker and uses private container-only
HTTPS trust. On 2026-11-01 it passed actual provider login, native permission
denials, logout replay rejection, renewal after expiry and provider revocation
with the production Core client. Its synthetic HTTP driver and HTTPS callback
receiver do not qualify the installed Windows browser UI or private URI callback.
The released application now adopts the compatible QQQ RC.21 tuple; this
historical experimental result does not by itself qualify a different host configuration. Provider refresh tokens stay on the server. The short lifetime test does
not prove months or years of unattended use. Do not route the private Windows
callback to a live companion whose connection must remain intact.
The setup follows the official [2026.8 Compose definition](https://goauthentik.io/version/2026.8/lifecycle/container/compose.yml),
[bootstrap contract](https://docs.goauthentik.io/install-config/automated-install/)
and [OAuth provider contract](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/).
The repository supplies the repeatable local setup; provider/browser acceptance
is reported separately from TABLE authentication and printing workload tests.
## Initializer regression tests
With Pester 5 available, run `dev/tests/Authentik.Tests.ps1`. They use temporary
files only: no Docker, HTTP, installed service or registry access is required.
Disable Pester's unrelated registry fixture on Windows:
```powershell
$configuration = New-PesterConfiguration
$configuration.Run.Path = './dev/tests/Authentik.Tests.ps1'
$configuration.Run.Exit = $true
$configuration.TestRegistry.Enabled = $false
Invoke-Pester -Configuration $configuration
```
+30
View File
@@ -0,0 +1,30 @@
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Deterministic>true</Deterministic>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<CoreAssemblyPath Condition="'$(CoreAssemblyPath)' == ''">/core/Qqq.PrintAgent.Core.dll</CoreAssemblyPath>
</PropertyGroup>
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(CI)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<RestoreLockedMode>true</RestoreLockedMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
</ItemGroup>
<Target Name="RequireProductionCore" BeforeTargets="ResolveAssemblyReferences">
<Error Condition="!Exists('$(CoreAssemblyPath)')" Text="Build the pinned production Core source first and provide CoreAssemblyPath; fixture stubs are not supported." />
</Target>
</Project>
+10
View File
@@ -0,0 +1,10 @@
<!-- Copyright 2026 Kingsrook, LLC; SPDX-License-Identifier: Apache-2.0 -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
</ItemGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build
# Exact reviewed published Core source; no workstation DLL or mutable branch.
ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
COPY dev/Directory.Build.props dev/Directory.Packages.props dev/.editorconfig dev/global.json /fixture/dev/
WORKDIR /fixture/dev/client
COPY dev/client .
RUN dotnet build -c Release -p:RestoreLockedMode=true --nologo -o /out
RUN dotnet format --verify-no-changes --severity warn --no-restore
RUN dotnet /out/Workload.dll --check-discovery
# SDK image is reused deliberately: one pinned image, no hidden host SDK dependency.
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
WORKDIR /app
COPY --from=build /out .
ENV DOTNET_EnableDiagnostics=0
ENTRYPOINT ["dotnet","/app/Workload.dll"]
+27
View File
@@ -0,0 +1,27 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build
WORKDIR /source
COPY pom.xml mvnw ./
COPY .mvn .mvn
COPY checkstyle checkstyle
COPY src src
COPY dev/java dev/java
COPY dev/native-auth/java dev/native-auth/java
COPY LICENSE NOTICE ./
RUN chmod +x mvnw && ./mvnw -B -Pui-demo -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
RUN mkdir -p /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes dev/java/*.java
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
ARG SOURCE_COMMIT=unknown
ENV PRINTING_SOURCE_COMMIT=$SOURCE_COMMIT
WORKDIR /app
COPY --from=build /source/target/classes classes
COPY --from=build /source/target/runtime lib
COPY --from=build /fixture-classes fixture
COPY dev/log4j2.xml /app/log4j2.xml
COPY dev/run-tests.sh /app/run-tests.sh
RUN chmod 0555 /app/run-tests.sh
ENTRYPOINT ["java","-Xmx768m","-Dlog4j.configurationFile=/app/log4j2.xml","-cp","/app/classes:/app/lib/*:/app/fixture"]
CMD ["com.kingsrook.qbits.printing.DevHost"]
+397
View File
@@ -0,0 +1,397 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
<#
.SYNOPSIS
Prepares or checks the optional, owned Authentik development fixture.
.DESCRIPTION
Initialize only writes private state. Test configures a fresh synthetic provider
and checks its real HTTP discovery and rejection boundary; it never automates a
user login or changes QQQ authentication. The common development entrypoint owns
all Docker lifecycle operations. Provider response bodies and secrets are never
printed, including on errors.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Initialize', 'Test')][string] $Action,
[Parameter(Mandatory)][string] $StateDirectory,
[Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName,
[ValidateRange(1024, 65535)][int] $Port = 19010,
[ValidateRange(10, 300)][int] $ReadyTimeoutSeconds = 180
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
<# .SYNOPSIS Generates a private random value without placing it on a command line. #>
function Get-FixtureSecret
{
[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
<# .SYNOPSIS Limits the helper to an existing state directory outside the checkout. #>
function Get-FixtureState
{
param([string] $Directory)
$state = [IO.Path]::GetFullPath($Directory)
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison))
{
throw 'Development secrets must be outside the source checkout.'
}
if(-not (Test-Path -LiteralPath $state -PathType Container) -or -not (Test-Path -LiteralPath (Join-Path $state 'compose.env') -PathType Leaf))
{
throw 'The common development entrypoint must create protected state and compose.env first.'
}
$ancestor = [IO.DirectoryInfo]::new($state)
while($null -ne $ancestor)
{
if(($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
{
throw 'Development state cannot use a symbolic link or reparse point.'
}
$ancestor = $ancestor.Parent
}
return $state
}
<# .SYNOPSIS Reads private configuration without echoing malformed JSON or credential values. #>
function Get-FixtureConfiguration
{
param([string] $Path)
try
{
$value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
foreach($field in @('project', 'port', 'adminPassword', 'bootstrapToken', 'clientId', 'clientSecret', 'username', 'userPassword'))
{
if(-not $value.ContainsKey($field) -or $null -eq $value[$field]) { throw 'Missing field.' }
}
return $value
}
catch { throw 'The private Authentik configuration is unreadable or incomplete; its contents are withheld.' }
}
<# .SYNOPSIS Performs bounded same-origin HTTP without redirects or response-body diagnostics. #>
function Invoke-FixtureHttp
{
param(
[Parameter(Mandatory)][Net.Http.HttpClient] $Client,
[Parameter(Mandatory)][string] $Path,
[ValidateSet('GET', 'POST')][string] $Method = 'GET',
[object] $Body,
[string] $Token,
[int[]] $ExpectedStatus = @(200)
)
if(-not $Path.StartsWith('/', [StringComparison]::Ordinal) -or $Path.StartsWith('//', [StringComparison]::Ordinal))
{
throw 'Only an absolute path on the fixture origin is permitted.'
}
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path)
$timeout = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(15))
$response = $null
$status = 0
try
{
if($Token)
{
$request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token)
}
if($null -ne $Body)
{
$request.Content = [Net.Http.StringContent]::new(($Body | ConvertTo-Json -Depth 12 -Compress), [Text.Encoding]::UTF8, 'application/json')
}
$response = $Client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead, $timeout.Token).GetAwaiter().GetResult()
$status = [int]$response.StatusCode
if($status -notin $ExpectedStatus)
{
throw "Fixture HTTP status $status was not expected. Response content is withheld."
}
$stream = $response.Content.ReadAsStreamAsync($timeout.Token).GetAwaiter().GetResult()
$bytes = [IO.MemoryStream]::new()
try
{
$buffer = [byte[]]::new(8192)
while(($read = $stream.ReadAsync($buffer, 0, $buffer.Length, $timeout.Token).GetAwaiter().GetResult()) -gt 0)
{
if($bytes.Length + $read -gt 524288)
{
throw 'Fixture response exceeded the 512 KiB limit.'
}
$bytes.Write($buffer, 0, $read)
}
$text = [Text.Encoding]::UTF8.GetString($bytes.ToArray())
$data = if($text.Length -gt 0 -and $response.Content.Headers.ContentType.MediaType -eq 'application/json')
{
ConvertFrom-Json -InputObject $text -AsHashtable
}
else { $null }
return @{ Status = $status; Data = $data; Location = $response.Headers.Location }
}
finally
{
$bytes.Dispose()
$stream.Dispose()
}
}
catch
{
# The failing API may contain passwords/tokens in its error response.
$operation = [uri]::new($Client.BaseAddress, $Path).AbsolutePath
throw "Fixture $Method $operation failed (HTTP $status; 0 means no response). Response content and authentication values are withheld."
}
finally
{
if($null -ne $response) { $response.Dispose() }
$request.Dispose()
$timeout.Dispose()
}
}
<# .SYNOPSIS Requires one exact default object rather than choosing an arbitrary provider resource. #>
function Get-FixtureObject
{
param([Net.Http.HttpClient] $Client, [string] $Path, [string] $Token)
$page = (Invoke-FixtureHttp -Client $Client -Path $Path -Token $Token).Data
if($null -eq $page -or $page.results.Count -ne 1)
{
throw 'A required Authentik default object is missing or ambiguous.'
}
return $page.results[0]
}
$state = Get-FixtureState -Directory $StateDirectory
$envPath = Join-Path $state 'compose.env'
$secretPath = Join-Path $state 'auth-secrets.json'
foreach($path in @($envPath, $secretPath))
{
if((Test-Path -LiteralPath $path) -and ((Get-Item -LiteralPath $path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
{
throw 'Development configuration cannot use a symbolic link or reparse point.'
}
}
if($Action -eq 'Initialize')
{
if(Test-Path -LiteralPath $secretPath)
{
$existing = Get-FixtureConfiguration -Path $secretPath
if($existing.project -ne $ProjectName -or $existing.port -ne $Port)
{
throw 'Existing Authentik state belongs to a different project or port.'
}
$existingEnvironment = Get-Content -LiteralPath $envPath -Raw
foreach($key in @('AUTHENTIK_HTTP_PORT', 'AUTHENTIK_POSTGRES_PASSWORD', 'AUTHENTIK_SECRET_KEY', 'AUTHENTIK_BOOTSTRAP_PASSWORD_HASH', 'AUTHENTIK_BOOTSTRAP_TOKEN'))
{
if([regex]::Matches($existingEnvironment, '(?m)^' + $key + '=').Count -ne 1)
{
throw 'The private Authentik environment is incomplete or ambiguous; use a fresh owned state directory.'
}
}
return
}
$contents = Get-Content -LiteralPath $envPath -Raw
if($contents -match '(?m)^AUTHENTIK_')
{
throw 'Partial Authentik initialization exists. Use a fresh owned state directory.'
}
$adminPassword = Get-FixtureSecret
$salt = Get-FixtureSecret
$passwordHash = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($adminPassword, [Text.Encoding]::UTF8.GetBytes($salt), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32))
$secrets = [ordered]@{
project = $ProjectName
port = $Port
adminPassword = $adminPassword
bootstrapToken = Get-FixtureSecret
clientId = $ProjectName
clientSecret = Get-FixtureSecret
username = 'printing-developer'
userPassword = Get-FixtureSecret
}
$secrets | ConvertTo-Json | Set-Content -LiteralPath $secretPath -Encoding utf8
$values = [ordered]@{
AUTHENTIK_HTTP_PORT = $Port
AUTHENTIK_POSTGRES_PASSWORD = Get-FixtureSecret
AUTHENTIK_SECRET_KEY = Get-FixtureSecret
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH = 'pbkdf2_sha256$1000000$' + $salt + '$' + $passwordHash
AUTHENTIK_BOOTSTRAP_TOKEN = $secrets.bootstrapToken
}
# Single quotes preserve the dollar signs in Django's password hash.
$lines = @('') + @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" })
Add-Content -LiteralPath $envPath -Value $lines -Encoding utf8
if(-not $IsWindows)
{
[IO.File]::SetUnixFileMode($secretPath, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
}
return
}
$secrets = Get-FixtureConfiguration -Path $secretPath
if($secrets.project -cne $ProjectName -or $secrets.port -ne $Port -or $secrets.clientId -cne $ProjectName -or $secrets.username -cne 'printing-developer')
{
throw 'Authentik state does not match the owned development project.'
}
$handler = [Net.Http.HttpClientHandler]::new()
$handler.AllowAutoRedirect = $false
$handler.UseProxy = $false
$handler.UseCookies = $false
$client = [Net.Http.HttpClient]::new($handler)
$client.BaseAddress = [uri]"http://127.0.0.1:$Port"
$client.Timeout = [Threading.Timeout]::InfiniteTimeSpan
try
{
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($ReadyTimeoutSeconds)
$ready = $false
do
{
try
{
$authorization = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent' -Token $secrets.bootstrapToken
$invalidation = Get-FixtureObject -Client $client -Path '/api/v3/flows/instances/?slug=default-provider-invalidation-flow' -Token $secrets.bootstrapToken
$ready = $true
}
catch
{
if([DateTimeOffset]::UtcNow -ge $deadline) { throw 'Authentik did not become ready before the bounded startup deadline.' }
Start-Sleep -Seconds 1
}
} while(-not $ready)
$mappingIds = @(foreach($scope in @('openid', 'profile', 'email', 'offline_access'))
{
(Get-FixtureObject -Client $client -Path "/api/v3/propertymappings/provider/scope/?scope_name=$scope" -Token $secrets.bootstrapToken).pk
})
$providerPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/providers/oauth2/?client_id=' + $secrets.clientId) -Token $secrets.bootstrapToken).Data
$callback = "http://127.0.0.1:$Port/reference-callback/"
if($providerPage.results.Count -eq 0)
{
$provider = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/providers/oauth2/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{
name = $ProjectName
authorization_flow = $authorization.pk
invalidation_flow = $invalidation.pk
client_type = 'confidential'
grant_types = @('authorization_code', 'refresh_token')
client_id = $secrets.clientId
client_secret = $secrets.clientSecret
access_code_validity = 'minutes=1'
access_token_validity = 'minutes=2'
refresh_token_validity = 'minutes=10'
redirect_uris = @(@{ matching_mode = 'strict'; url = $callback })
property_mappings = $mappingIds
issuer_mode = 'per_provider'
}).Data
}
elseif($providerPage.results.Count -eq 1)
{
$provider = $providerPage.results[0]
if($provider.name -ne $ProjectName) { throw 'The fixture provider identity changed.' }
}
else { throw 'The fixture provider identity is ambiguous.' }
if($provider.redirect_uris.Count -ne 1 -or $provider.redirect_uris[0].matching_mode -cne 'strict' -or
$provider.redirect_uris[0].url -cne $callback -or $provider.client_type -cne 'confidential' -or
$provider.access_token_validity -cne 'minutes=2' -or $provider.refresh_token_validity -cne 'minutes=10' -or
(@($provider.grant_types | Sort-Object) -join ',') -cne 'authorization_code,refresh_token' -or
@($mappingIds | Where-Object { $_ -notin $provider.property_mappings }).Count -ne 0)
{
throw 'The existing provider configuration differs from the bounded reference settings.'
}
$applicationPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/applications/?slug=' + $ProjectName) -Token $secrets.bootstrapToken).Data
if($applicationPage.results.Count -eq 0)
{
$null = Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/applications/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ name = $ProjectName; slug = $ProjectName; provider = $provider.pk; policy_engine_mode = 'all' }
}
elseif($applicationPage.results.Count -ne 1 -or $applicationPage.results[0].provider -ne $provider.pk)
{
throw 'The fixture application does not match its provider.'
}
$userPage = (Invoke-FixtureHttp -Client $client -Path ('/api/v3/core/users/?username=' + $secrets.username) -Token $secrets.bootstrapToken).Data
if($userPage.results.Count -eq 0)
{
$user = (Invoke-FixtureHttp -Client $client -Method POST -Path '/api/v3/core/users/' -Token $secrets.bootstrapToken -ExpectedStatus @(201) -Body @{ username = $secrets.username; name = 'Printing development user'; email = 'printing-developer@example.invalid'; is_active = $true; type = 'internal' }).Data
}
elseif($userPage.results.Count -eq 1)
{
$user = $userPage.results[0]
if($user.name -cne 'Printing development user') { throw 'The synthetic user identity changed.' }
}
else { throw 'The synthetic user identity is ambiguous.' }
# Reusing the same generated value also recovers an interrupted first setup.
$null = Invoke-FixtureHttp -Client $client -Method POST -Path ("/api/v3/core/users/$($user.pk)/set_password/") -Token $secrets.bootstrapToken -ExpectedStatus @(204) -Body @{ password = $secrets.userPassword }
$issuer = "http://127.0.0.1:$Port/application/o/$ProjectName/"
$discovery = (Invoke-FixtureHttp -Client $client -Path "/application/o/$ProjectName/.well-known/openid-configuration").Data
if($discovery.issuer -cne $issuer -or 'S256' -notin $discovery.code_challenge_methods_supported)
{
throw 'The real provider discovery does not match the fixture issuer and S256 capability.'
}
foreach($endpoint in @('authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'))
{
$uri = [uri]$discovery[$endpoint]
if($uri.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port")
{
throw 'Provider discovery returned an endpoint outside the fixture origin.'
}
}
$verifier = Get-FixtureSecret
$challenge = [Convert]::ToBase64String([Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$stateValue = Get-FixtureSecret
$authorize = "/application/o/authorize/?client_id=$($secrets.clientId)&response_type=code&scope=openid%20profile%20email%20offline_access&state=$stateValue&code_challenge=$challenge&code_challenge_method=S256&prompt=login&redirect_uri="
$accepted = Invoke-FixtureHttp -Client $client -Path ($authorize + [uri]::EscapeDataString($callback)) -ExpectedStatus @(302, 303)
if($null -eq $accepted.Location)
{
throw 'The registered redirect did not continue to the real provider login flow.'
}
$login = [uri]::new($client.BaseAddress, $accepted.Location)
if($login.GetLeftPart([UriPartial]::Authority) -cne "http://127.0.0.1:$Port" -or -not $login.AbsolutePath.StartsWith('/if/flow/', [StringComparison]::Ordinal))
{
throw 'The registered redirect did not continue to the same-origin provider login flow.'
}
# Change only the callback: a generic malformed authorization request is not proof.
$invalidRedirect = [uri]::EscapeDataString("http://127.0.0.1:$Port/not-registered/")
$null = Invoke-FixtureHttp -Client $client -Path ($authorize + $invalidRedirect) -ExpectedStatus @(400)
$qqqPortMatch = [regex]::Match((Get-Content -LiteralPath $envPath -Raw), '(?m)^QQQ_HTTP_PORT=[''"]?(\d+)[''"]?\s*$')
if(-not $qqqPortMatch.Success) { throw 'The common environment has no QQQ HTTP port.' }
$qqqHandler = [Net.Http.HttpClientHandler]::new()
$qqqHandler.AllowAutoRedirect = $false
$qqqHandler.UseProxy = $false
$qqqClient = [Net.Http.HttpClient]::new($qqqHandler)
try
{
$qqqClient.BaseAddress = [uri]("http://127.0.0.1:" + $qqqPortMatch.Groups[1].Value)
$qqqClient.Timeout = [Threading.Timeout]::InfiniteTimeSpan
$qqqClient.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-dev-reference')
$version = (Invoke-FixtureHttp -Client $qqqClient -Path '/qqq/v1/version').Data
}
finally { $qqqClient.Dispose() }
if($version.apiVersion -ne '1.8.0' -or 'nativeSessionLogin' -in $version.features)
{
throw 'The core capability baseline changed; review the Authentik qualification scope before proceeding.'
}
[ordered]@{
status = 'PASS'
observedUtc = [DateTimeOffset]::UtcNow.ToString('O')
project = $ProjectName
providerOrigin = "http://127.0.0.1:$Port"
providerVersion = '2026.8.5'
discovery = 'PASS'
registeredRedirect = 'LOGIN_REQUIRED'
unregisteredRedirect = 'REJECTED'
qqqApiVersion = $version.apiVersion
nativeSessionLogin = 'UNAVAILABLE_EXPECTED'
browserLogin = 'NOT_RUN_OPERATOR_REQUIRED'
renewal = 'NOT_RUN'
windowsCallback = 'NOT_RUN_REQUIRES_QUALIFIED_CORE_AND_HTTPS'
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8
Write-Output 'Authentik reference discovery and redirect rejection passed; QQQ native browser login remains unavailable on RC.20.'
}
catch
{
@{ status = 'FAILED'; observedUtc = [DateTimeOffset]::UtcNow.ToString('O'); project = $ProjectName; message = 'Reference checks failed; no browser or native-login success is claimed.' } |
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-result.json') -Encoding utf8
throw
}
finally
{
$client.Dispose()
}
+502
View File
@@ -0,0 +1,502 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
<#
.SYNOPSIS
Starts, tests, or removes one owned local Docker printing environment.
.DESCRIPTION
Uses synthetic accounts and documents. Never imports installed device credentials,
contacts printers, changes host trust, or prunes shared Docker resources.
#>
[CmdletBinding()]
param(
[ValidateSet('Start', 'Test', 'Stop')][string]$Action = 'Test',
[string]$StateDirectory,
[switch]$Authentik,
[switch]$ExperimentalNativeAuth,
[ValidateSet(40, 10000)][int]$Workload = 40,
[ValidateRange(1024, 65535)][int]$QqqPort = 19081,
[ValidateRange(1024, 65535)][int]$MinioPort = 19095,
[ValidateRange(1024, 65535)][int]$AuthentikPort = 19010
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$ephemeral = $Action -eq 'Test' -and [string]::IsNullOrWhiteSpace($StateDirectory)
if ($ExperimentalNativeAuth)
{
if ($Action -ne 'Test')
{
throw 'Experimental native authentication is a disposable Test-only profile.'
}
$Authentik = $true
}
function Assert-StateLocation([string]$Path)
{
<# .SYNOPSIS Rejects links before any private state is read or written. #>
$ancestor = [IO.DirectoryInfo]::new($Path)
while ($null -ne $ancestor)
{
if (![string]::IsNullOrEmpty($ancestor.LinkTarget) -or ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))
{
throw 'Development state cannot use a symbolic link or reparse point.'
}
$ancestor = $ancestor.Parent
}
foreach ($name in @('owner.json', 'compose.env', 'compose.yaml', 'compose.authentik.yaml', 'compose.native-auth.yaml', 'auth-secrets.json', 'qqq-result.json', 'auth-result.json', 'native-input.json', 'native-auth-result.json', 'native-auth-server-errors.json'))
{
$file = [IO.FileInfo]::new((Join-Path $Path $name))
if (![string]::IsNullOrEmpty($file.LinkTarget) -or ($file.Exists -and ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))
{
throw 'Development state files cannot use symbolic links or reparse points.'
}
}
}
function Get-RecordedAuthentikPort
{
<# .SYNOPSIS Restores the existing environment port without reading it from caller defaults. #>
$lines = @(Get-Content -LiteralPath $script:envFile | Where-Object { $_.StartsWith('AUTHENTIK_HTTP_PORT=', [StringComparison]::Ordinal) })
if ($lines.Count -ne 1 -or $lines[0] -cnotmatch '^AUTHENTIK_HTTP_PORT=([0-9]{4,5})$')
{
throw 'Recorded Authentik port is missing or invalid.'
}
$port = [int]$Matches[1]
if ($port -lt 1024 -or $port -gt 65535)
{
throw 'Recorded Authentik port is outside the supported range.'
}
return $port
}
function Get-FixtureSecret
{
<# .SYNOPSIS Generates only disposable local credentials. #>
return [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
}
function Protect-State([string]$Path)
{
<# .SYNOPSIS Restricts the generated state to its user and local administrators. #>
if ($IsWindows)
{
$acl = [Security.AccessControl.DirectorySecurity]::new()
$acl.SetAccessRuleProtection($true, $false)
foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User,
[Security.Principal.SecurityIdentifier]::new('S-1-5-18'), [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')))
{
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
else
{
[IO.File]::SetUnixFileMode($Path, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute)
}
}
function Set-FixtureEnvironment
{
[CmdletBinding(SupportsShouldProcess)]
param([string]$Name, [string]$Value)
<# .SYNOPSIS Updates one safe dotenv field without displaying secret values. #>
if ($Name -cnotmatch '^[A-Z_]+$' -or $Value -match '[\r\n]')
{
throw 'Unsafe generated environment field.'
}
if (!$PSCmdlet.ShouldProcess($Name, 'Write protected fixture setting'))
{
return
}
$lines = @()
if (Test-Path -LiteralPath $script:envFile)
{
$lines = @(Get-Content -LiteralPath $script:envFile | Where-Object { !$_.StartsWith($Name + '=', [StringComparison]::Ordinal) })
}
[IO.File]::WriteAllLines($script:envFile, @($lines) + @($Name + '=' + $Value))
}
function Invoke-Compose([string[]]$Arguments)
{
<# .SYNOPSIS Runs only this recorded Compose project. #>
& docker compose @script:composeArguments @Arguments
if ($LASTEXITCODE -ne 0)
{
throw 'Owned Docker Compose operation failed; inspect local command output.'
}
}
function Assert-OwnedResource
{
<# .SYNOPSIS Refuses cleanup if any project resource lacks the ownership label. #>
$ids = @(& docker ps -aq --filter "label=com.docker.compose.project=$script:project")
if ($LASTEXITCODE -ne 0)
{
throw 'Cannot inspect owned Docker containers.'
}
foreach ($id in $ids)
{
$label = & docker inspect $id --format '{{index .Config.Labels "io.qrun.printing.dev"}}'
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project)
{
throw 'Container ownership mismatch; cleanup refused.'
}
}
$networks = @(& docker network ls -q --filter "label=com.docker.compose.project=$script:project")
if ($LASTEXITCODE -ne 0)
{
throw 'Cannot inspect owned Docker networks.'
}
foreach ($network in $networks)
{
$label = & docker network inspect $network --format '{{index .Labels "io.qrun.printing.dev"}}'
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project)
{
throw 'Network ownership mismatch; cleanup refused.'
}
}
$volumes = @(& docker volume ls -q --filter "label=com.docker.compose.project=$script:project")
if ($LASTEXITCODE -ne 0)
{
throw 'Cannot inspect owned Docker volumes.'
}
foreach ($volume in $volumes)
{
$label = & docker volume inspect $volume --format '{{index .Labels "io.qrun.printing.dev"}}'
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project)
{
throw 'Volume ownership mismatch; cleanup refused.'
}
}
}
function Stop-OwnedEnvironment
{
[CmdletBinding(SupportsShouldProcess)]
param()
<# .SYNOPSIS Removes only this environment's containers, network and synthetic volumes. #>
if (!$PSCmdlet.ShouldProcess($script:project, 'Remove this owned development environment and its synthetic data'))
{
return
}
Assert-OwnedResource
Invoke-Compose @('--profile', 'test', '--profile', 'auth', '--profile', 'native-auth', 'down', '--remove-orphans', '--volumes', '--timeout', '30')
}
function ConvertTo-ProviderErrorSummary([string[]]$Lines)
{
<# .SYNOPSIS Retains only exception types and bounded source-frame coordinates. #>
$types = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$frames = [Collections.Generic.List[object]]::new()
$warnings = @{invalidClientIdentifier = $false; nonApplicableAuthenticationFlow = $false; failedToResolveApplication = $false}
foreach ($line in ($Lines | Select-Object -Last 1000))
{
if ($line.Length -gt 65536) { continue }
$warnings.invalidClientIdentifier = $warnings.invalidClientIdentifier -or $line.Contains('Invalid client identifier', [StringComparison]::Ordinal)
$warnings.nonApplicableAuthenticationFlow = $warnings.nonApplicableAuthenticationFlow -or $line.Contains('Non-applicable authentication flow', [StringComparison]::Ordinal)
$warnings.failedToResolveApplication = $warnings.failedToResolveApplication -or $line.Contains('failed to resolve application', [StringComparison]::Ordinal)
try { $record = ConvertFrom-Json -InputObject $line -AsHashtable -ErrorAction Stop }
catch { continue }
if ($record -isnot [Collections.IDictionary]) { continue }
foreach ($exception in @($record['exception']))
{
if ($exception -isnot [Collections.IDictionary]) { continue }
$type = [string]$exception['exc_type']
if ($types.Count -lt 16 -and ($type -cmatch '^[A-Za-z_][A-Za-z0-9_.]{0,119}(Error|Exception)$' -or $type -cin @('PermissionDenied', 'NotAuthenticated', 'AuthenticationFailed', 'Throttled'))) { [void]$types.Add($type) }
foreach ($frame in @($exception['frames']))
{
if ($frames.Count -ge 12 -or $frame -isnot [Collections.IDictionary]) { continue }
$filename = [string]$frame['filename']
$function = [string]$frame['name']
$lineNumber = 0
if ($filename.Length -le 300 -and $filename -cmatch '^/authentik/[A-Za-z0-9_./-]+\.py$' -and
$function -cmatch '^[A-Za-z0-9_<>.]{1,120}$' -and
[int]::TryParse([string]$frame['lineno'], [ref]$lineNumber) -and $lineNumber -gt 0)
{
$frames.Add(@{filename = $filename; line = $lineNumber; function = $function})
}
}
}
}
return @{exceptionTypes = @($types | Sort-Object); frames = @($frames.ToArray()); warnings = $warnings}
}
function Save-ProviderErrorSummary
{
<# .SYNOPSIS Reads bounded logs only from the exact owned synthetic provider. #>
$ids = @(& docker ps -aq --filter "label=com.docker.compose.project=$script:project" --filter 'label=com.docker.compose.service=authentik-server')
if ($LASTEXITCODE -ne 0 -or $ids.Count -ne 1 -or $ids[0] -cnotmatch '^[a-f0-9]{12,64}$') { return }
$label = & docker inspect $ids[0] --format '{{index .Config.Labels "io.qrun.printing.dev"}}'
if ($LASTEXITCODE -ne 0 -or $label -cne $script:project) { throw 'Provider diagnostic ownership mismatch.' }
$lines = @(& docker logs --tail 1000 $ids[0] 2>&1 | ForEach-Object { $_.ToString() })
if ($LASTEXITCODE -ne 0) { return }
$summary = ConvertTo-ProviderErrorSummary $lines
$lines = $null
Assert-StateLocation $StateDirectory
$summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $StateDirectory 'native-auth-server-errors.json')
}
function Wait-OwnedContainer([string]$Id, [int]$Seconds)
{
<# .SYNOPSIS Observes bounded completion without accessing application data. #>
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
do
{
$state = & docker inspect $Id --format '{{.State.Running}}|{{.State.ExitCode}}'
if ($LASTEXITCODE -ne 0)
{
throw 'Owned fixture container disappeared.'
}
if ($state.StartsWith('false|', [StringComparison]::Ordinal))
{
return [int]$state.Split('|')[1]
}
Start-Sleep -Seconds 1
} while ([DateTime]::UtcNow -lt $deadline)
throw 'Bounded fixture deadline expired; owned resources will be cleaned.'
}
function Assert-WorkloadClientSucceeded([string]$Id, [int]$ExitCode, [string]$Run, [string]$Reports)
{
<# .SYNOPSIS Preserves private client evidence and reports only fixed failure classifications. #>
if ($ExitCode -eq 0) { return }
$stage = 'unknown'
$failureType = 'UnknownFailure'
try
{
$path = Join-Path $Reports 'result.json'
& docker cp "${Id}:/work/$Run/result.json" $path 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $path -PathType Leaf) -and (Get-Item -LiteralPath $path).Length -le 65536)
{
$result = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable
if ($result -is [Collections.IDictionary] -and $result['success'] -ceq $false)
{
if ($result['stage'] -is [string] -and $result['stage'] -cin @('input', 'native_login_and_channels', 'workload', 'logout'))
{
$stage = $result['stage']
}
if ($result['error'] -is [string] -and $result['error'] -cin @('QqqSessionException', 'HttpRequestException', 'IOException', 'InvalidDataException', 'JsonException', 'OperationCanceledException', 'TaskCanceledException', 'UnauthorizedAccessException', 'WebSocketException', 'TimeoutException', 'InvalidOperationException'))
{
$failureType = $result['error']
}
}
}
}
catch
{
# Missing or malformed diagnostics must not hide the failed client or expose raw exception text.
$stage = 'unknown'
$failureType = 'UnknownFailure'
}
throw "Workload client failed at $stage ($failureType); see protected results."
}
if ([string]::IsNullOrWhiteSpace($StateDirectory))
{
if ($Action -eq 'Stop')
{
throw 'Stop requires the state directory printed by Start.'
}
$base = if ($IsWindows)
{
Join-Path $env:LOCALAPPDATA 'QQQ/PrintingDevelopment'
}
else
{
Join-Path $HOME '.local/state/qqq-printing-development'
}
$StateDirectory = Join-Path $base ('environment-' + [Guid]::NewGuid().ToString('N'))
}
$StateDirectory = [IO.Path]::GetFullPath($StateDirectory)
if ($StateDirectory.StartsWith($sourceRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase))
{
throw 'Keep secret state outside the repository.'
}
Assert-StateLocation $StateDirectory
$ownerFile = Join-Path $StateDirectory 'owner.json'
$script:envFile = Join-Path $StateDirectory 'compose.env'
$createdState = $false
if (Test-Path -LiteralPath $StateDirectory)
{
if (!(Test-Path -LiteralPath $ownerFile))
{
throw 'Existing state directory lacks this tool ownership record.'
}
$owner = Get-Content -LiteralPath $ownerFile -Raw | ConvertFrom-Json
if ($owner.sourceRoot -cne $sourceRoot -or $owner.project -cnotmatch '^qbit-printing-dev-[a-f0-9]{32}$')
{
throw 'State ownership mismatch.'
}
$script:project = $owner.project
$Authentik = [bool]$owner.authentik
$savedNativeProfile = $null -ne $owner.PSObject.Properties['experimentalNativeAuth'] -and [bool]$owner.experimentalNativeAuth
if ($ExperimentalNativeAuth -and !$savedNativeProfile)
{
throw 'An existing standard environment cannot be repurposed for experimental native authentication.'
}
$ExperimentalNativeAuth = $savedNativeProfile
if ($ExperimentalNativeAuth -and $Action -eq 'Start')
{
throw 'Experimental native authentication is a disposable Test-only profile.'
}
}
else
{
if ($Action -eq 'Stop')
{
throw 'Owned state directory does not exist.'
}
[IO.Directory]::CreateDirectory($StateDirectory) | Out-Null
Protect-State $StateDirectory
$createdState = $true
$script:project = 'qbit-printing-dev-' + [Guid]::NewGuid().ToString('N')
$commit = (& git -C $sourceRoot rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[a-f0-9]{40}$')
{
throw 'Cannot record source identity.'
}
foreach ($field in @{PRINTING_DEV_PROJECT = $project; DEV_SOURCE_DIR = $sourceRoot.Replace('\', '/'); SOURCE_COMMIT = $commit; QQQ_HTTP_PORT = "$QqqPort"; MINIO_HTTP_PORT = "$MinioPort"; MINIO_ROOT_USER = 'printing-development'; MINIO_ROOT_PASSWORD = (Get-FixtureSecret); QQQ_DATABASE_PASSWORD = (Get-FixtureSecret); QQQ_ADMIN_PASSWORD = (Get-FixtureSecret); QQQ_MANAGEMENT_TOKEN = (Get-FixtureSecret) }.GetEnumerator())
{
Set-FixtureEnvironment $field.Key $field.Value
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.yaml') -Destination (Join-Path $StateDirectory 'compose.yaml')
if ($Authentik)
{
& (Join-Path $PSScriptRoot 'Invoke-Authentik.ps1') -Action Initialize -StateDirectory $StateDirectory -ProjectName $project -Port $AuthentikPort
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.authentik.yaml') -Destination (Join-Path $StateDirectory 'compose.authentik.yaml')
}
if ($ExperimentalNativeAuth)
{
& (Join-Path $PSScriptRoot 'Invoke-NativeAuth.ps1') -Action Initialize -StateDirectory $StateDirectory -ProjectName $project
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'compose.native-auth.yaml') -Destination (Join-Path $StateDirectory 'compose.native-auth.yaml')
}
@{project = $project; sourceRoot = $sourceRoot; sourceCommit = $commit; authentik = [bool]$Authentik; experimentalNativeAuth = [bool]$ExperimentalNativeAuth; createdAt = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath $ownerFile
}
$script:composeArguments = @('--project-name', $project, '--env-file', $envFile, '-f', (Join-Path $StateDirectory 'compose.yaml'))
if ($Authentik)
{
$script:composeArguments += @('-f', (Join-Path $StateDirectory 'compose.authentik.yaml'))
}
if ($ExperimentalNativeAuth)
{
$script:composeArguments += @('-f', (Join-Path $StateDirectory 'compose.native-auth.yaml'))
}
if ($Action -eq 'Stop')
{
Stop-OwnedEnvironment
Write-Output "Stopped owned development environment. Reports remain in $StateDirectory"
return
}
if ($ExperimentalNativeAuth)
{
$nativePassed = $false
try
{
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'build', 'native-qqq', 'native-client')
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'up', '-d', 'authentik-server', 'authentik-worker', 'native-tls')
Invoke-Compose @('exec', '-T', 'native-tls', '/bin/sh', '/etc/caddy/copy-root.sh')
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'up', '-d', 'native-qqq')
Invoke-Compose @('--profile', 'auth', '--profile', 'native-auth', 'run', '--rm', '--use-aliases', 'native-client')
& (Join-Path $PSScriptRoot 'Invoke-NativeAuth.ps1') -Action VerifyResult -StateDirectory $StateDirectory -ProjectName $project
$nativePassed = $true
}
finally
{
try
{
if (!$nativePassed) { Save-ProviderErrorSummary }
}
finally { Stop-OwnedEnvironment }
Write-Output "Protected experimental native-auth result: $(Join-Path $StateDirectory 'native-auth-result.json')"
}
return
}
if ($Action -eq 'Start')
{
$services = @('minio', 'qqq')
$profiles = @()
if ($Authentik)
{
$profiles = @('--profile', 'auth'); $services += @('authentik-server', 'authentik-worker')
}
try
{
Invoke-Compose ($profiles + @('up', '-d', '--build') + $services); & (Join-Path $PSScriptRoot 'Test-DevelopmentHost.ps1') -StateDirectory $StateDirectory
}
catch
{
if ($createdState)
{
Stop-OwnedEnvironment
}; throw
}
Write-Output "Owned environment: $StateDirectory"
Write-Output 'Read QQQ_ADMIN_PASSWORD in its protected compose.env; username is developer. No password is logged.'
return
}
$testRun = 'run-' + [Guid]::NewGuid().ToString('N')
Set-FixtureEnvironment 'TEST_RUN' $testRun
Set-FixtureEnvironment 'WORKLOAD_MODE' $(if ($Workload -eq 10000)
{
'burst'
}
else
{
'idle'
})
$reports = Join-Path $StateDirectory $testRun
[IO.Directory]::CreateDirectory($reports) | Out-Null
$passed = $false
try
{
if ($Authentik)
{
Invoke-Compose @('--profile', 'auth', 'up', '-d', '--build', 'qqq', 'authentik-server', 'authentik-worker')
& (Join-Path $PSScriptRoot 'Test-DevelopmentHost.ps1') -StateDirectory $StateDirectory
& (Join-Path $PSScriptRoot 'Invoke-Authentik.ps1') -Action Test -StateDirectory $StateDirectory -ProjectName $project -Port (Get-RecordedAuthentikPort)
}
Invoke-Compose @('--profile', 'test', 'up', '-d', '--build', 'test-client')
$client = (& docker compose @composeArguments ps -a -q test-client).Trim()
$server = (& docker compose @composeArguments ps -a -q test-server).Trim()
if ($client -cnotmatch '^[a-f0-9]{64}$' -or $server -cnotmatch '^[a-f0-9]{64}$')
{
throw 'Owned fixture identity missing.'
}
$clientExit = Wait-OwnedContainer $client 1200
Assert-WorkloadClientSucceeded -Id $client -ExitCode $clientExit -Run $testRun -Reports $reports
$serverExit = Wait-OwnedContainer $server 45
foreach ($file in @('result.json', 'server-result.json', 'stopped.json', 's3/results.json', 's3/completed-checks.json'))
{
$destination = Join-Path $reports $file.Replace('/', '-')
& docker cp "${server}:/work/$testRun/$file" $destination
if ($LASTEXITCODE -ne 0)
{
throw 'Required sanitized fixture result missing.'
}
}
if ($clientExit -ne 0 -or $serverExit -ne 0)
{
throw 'Fixture failed; see protected results.'
}
$clientResult = Get-Content (Join-Path $reports 'result.json') -Raw | ConvertFrom-Json
$serverResult = Get-Content (Join-Path $reports 'server-result.json') -Raw | ConvertFrom-Json
$s3Result = Get-Content (Join-Path $reports 's3-results.json') -Raw | ConvertFrom-Json
if (!$clientResult.success -or !$serverResult.success -or !$s3Result.passed -or $clientResult.jobs -ne $Workload -or @($s3Result.checks).Count -ne 65)
{
throw 'Result invariants failed.'
}
$passed = $true
}
finally
{
& docker compose @composeArguments logs --no-color --no-log-prefix test-server test-client 2>&1 | Set-Content -LiteralPath (Join-Path $reports 'fixture.log')
if ($ephemeral)
{
Stop-OwnedEnvironment
}
else
{
Assert-OwnedResource
Invoke-Compose @('--profile', 'test', 'rm', '--stop', '--force', 'test-client', 'test-server', 'minio-test')
}
@{passed = $passed; workload = $Workload; physicalPrinting = $false; tlsQualified = $false; finishedAt = [DateTime]::UtcNow.ToString('O') } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $reports 'execution.json')
Write-Output "Protected developer-test results: $reports"
}
if (!$passed)
{
throw 'Development qualification did not pass.'
}
+110
View File
@@ -0,0 +1,110 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
<#
.SYNOPSIS
Prepares or verifies the opt-in, unpublished native-session Docker qualification.
.DESCRIPTION
Docker lifecycle belongs to Invoke-Dev.ps1. This helper creates only protected
synthetic input and reads a bounded sanitized report. It never installs trust,
uses host Maven caches, starts a browser, or operates the installed connector.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Initialize', 'VerifyResult')][string] $Action,
[Parameter(Mandatory)][string] $StateDirectory,
[Parameter(Mandatory)][ValidatePattern('^qbit-printing-dev-[a-z0-9-]{8,48}$')][string] $ProjectName
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$coreSource = 'ecf0f550a0357cec6387c2b54efca128a405a629'
$clientSource = '346fc285198908c47a2ee9db7b9adb53d5e88c3c'
$state = [IO.Path]::GetFullPath($StateDirectory)
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$comparison = if($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
if($state.Equals($repository, $comparison) -or $state.StartsWith($repository + [IO.Path]::DirectorySeparatorChar, $comparison))
{
throw 'Experimental credentials must be outside the checkout.'
}
$envFile = Join-Path $state 'compose.env'
$inputFile = Join-Path $state 'native-input.json'
$resultFile = Join-Path $state 'native-auth-result.json'
if(-not (Test-Path -LiteralPath $envFile -PathType Leaf))
{
throw 'The common entrypoint must initialize protected owned state first.'
}
if($Action -eq 'VerifyResult')
{
if(-not (Test-Path -LiteralPath $resultFile -PathType Leaf) -or (Get-Item -LiteralPath $resultFile).Length -gt 65536)
{
throw 'The experimental result is missing or exceeds its bounded size.'
}
try { $report = Get-Content -LiteralPath $resultFile -Raw | ConvertFrom-Json -AsHashtable }
catch { throw 'The experimental result is malformed; its contents are withheld.' }
$required = @('untrusted-tls-refused', 'typed-native-grant', 'real-provider-login', 'wrong-origin-cookie-denied', 'permission-denied-user', 'logout-replay-denied', 'server-refresh-after-expiry', 'provider-revocation-denied')
if($report.status -cne 'PASS' -or $report.scope -cne 'DEVELOPMENT_ONLY_UNRELEASED' -or
$report.coreSource -cne $coreSource -or $report.clientSource -cne $clientSource -or
$report.project -cne $ProjectName -or @($required | Where-Object { $_ -notin $report.checks }).Count -ne 0)
{
throw 'The experimental native-session qualification did not pass its exact source and required checks.'
}
Write-Output 'Experimental native-session provider checks passed; the candidate is unpublished and no installed/browser-UI acceptance is claimed.'
return
}
if((Test-Path -LiteralPath $inputFile) -or (Get-Content -LiteralPath $envFile -Raw) -match '(?m)^NATIVE_')
{
throw 'Experimental native tests require a fresh owned environment; partial state is not reused.'
}
try
{
$auth = Get-Content -LiteralPath (Join-Path $state 'auth-secrets.json') -Raw | ConvertFrom-Json -AsHashtable
if($auth.project -cne $ProjectName -or $auth.clientId -cne $ProjectName) { throw 'Wrong owner.' }
}
catch { throw 'Private Authentik bootstrap state is absent or does not match this project.' }
$random = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
$deniedPassword = [Convert]::ToBase64String($random).TrimEnd('=').Replace('+', '-').Replace('/', '_')
$issuer = "https://auth.printing.internal/application/o/$ProjectName/"
$inputData = [ordered]@{
project = $ProjectName
coreSource = $coreSource
clientSource = $clientSource
providerOrigin = 'https://auth.printing.internal'
qqqOrigin = 'https://qqq.printing.internal'
issuer = $issuer
callback = 'https://callback.printing.internal/oauth/callback'
adminToken = $auth.bootstrapToken
clientId = $auth.clientId
clientSecret = $auth.clientSecret
authorizedUsername = 'printing-authorized'
authorizedPassword = $auth.userPassword
deniedUsername = 'printing-denied'
deniedPassword = $deniedPassword
}
$inputData | ConvertTo-Json | Set-Content -LiteralPath $inputFile -Encoding utf8
@{ status = 'NOT_RUN'; scope = 'DEVELOPMENT_ONLY_UNRELEASED'; project = $ProjectName } |
ConvertTo-Json | Set-Content -LiteralPath $resultFile -Encoding utf8
$values = [ordered]@{
NATIVE_QQQ_DATABASE_PASSWORD = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
NATIVE_QQQ_REFRESH_KEY = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
NATIVE_AUTH_ISSUER = $issuer
NATIVE_AUTH_CLIENT_ID = $auth.clientId
NATIVE_AUTH_CLIENT_SECRET = $auth.clientSecret
NATIVE_AUTH_INPUT_FILE = $inputFile.Replace('\', '/')
NATIVE_AUTH_RESULT_FILE = $resultFile.Replace('\', '/')
}
foreach($entry in $values.GetEnumerator())
{
if($entry.Value -match '[\r\n'']') { throw 'Unsupported experimental environment value.' }
}
Add-Content -LiteralPath $envFile -Value @($values.GetEnumerator() | ForEach-Object { $_.Key + "='" + $_.Value + "'" }) -Encoding utf8
if(-not $IsWindows)
{
foreach($file in @($inputFile, $resultFile))
{
[IO.File]::SetUnixFileMode($file, [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
}
}
+153
View File
@@ -0,0 +1,153 @@
# Local Docker development
Use this repository to start a separate QQQ application or run synthetic printing
checks. Nothing imports an installed connector's pairing, accesses physical
printers, or needs a cloud account. Docker Compose and PowerShell 7.4+ are required;
Git identifies the checkout. Docker must support Linux/amd64 containers. The first
build downloads pinned public images, Maven dependencies and the verified Windows
Core source archive. Java and .NET run inside Docker; neither host SDK is required.
## Start, test and stop
From the repository root, the default command runs 65 S3 checks and 40 network
jobs, saves protected reports, and removes its containers and synthetic volumes:
```powershell
pwsh ./dev/Invoke-Dev.ps1 -Action Test
```
For a persistent development application, start it and keep the state-directory
path printed by the command:
```powershell
pwsh ./dev/Invoke-Dev.ps1 -Action Start
pwsh ./dev/Invoke-Dev.ps1 -Action Test -StateDirectory '<printed state directory>'
pwsh ./dev/Invoke-Dev.ps1 -Action Stop -StateDirectory '<printed state directory>'
```
Open `http://127.0.0.1:19081`. The standard QQQ TABLE login username is `developer`.
Read `QQQ_ADMIN_PASSWORD` from the generated, protected `compose.env` locally;
never paste that file into an issue or commit it. There is no default password.
The host provides native printing screens, settings, a dedicated MinIO document
bucket and scheduled retention. No printer is enrolled automatically in this host.
The test workload uses a separate disposable database and object store.
The current application dependency is QQQ RC.21, which supplies native session
login. The optional `-Authentik` diagnostic still asserts the older RC.20 API 1.8
absence of that feature; it is a retained historical check, not a current RC.21
login test. See [provider setup and fixture compatibility](AUTHENTIK.md).
Authentik uses PostgreSQL; the printing host itself uses H2, not PostgreSQL or MySQL.
```powershell
pwsh ./dev/Invoke-Dev.ps1 -Action Test -Workload 10000
```
The optional 10,000-job run keeps admission limits enabled, uses eight synthetic
clients across two tenants, and reports client process memory, latency and
receipt counts. It is a bounded burst, not a day-long soak or physical throughput
claim. The default 40-job run measures idle event delivery one job at a time.
## Isolation and cleanup
Each environment has a unique `qbit-printing-dev-<random>` Compose project and
matching ownership labels. All published ports bind only to `127.0.0.1`: QQQ19081,
MinIO19095 and optional Authentik19010. Override them with `-QqqPort`, `-MinioPort`
and `-AuthentikPort` when creating a new environment. Occupied ports fail startup;
the command never stops another service to free them. PostgreSQL is not published.
State defaults to the current user's application-data directory, outside the
checkout, with private filesystem permissions. A custom `-StateDirectory` must
also be outside the checkout. It holds generated fixture credentials, frozen
Compose files, ownership records and sanitized result files. Docker administrators
can inspect container configuration; use these credentials only for this fixture.
`Stop` validates container, network and volume ownership before removing that
project's resources. **It deletes that environment's synthetic databases and
objects.** It leaves reports and ordinary image/build caches in place and never
runs a global prune. A fresh one-command `Test` performs this cleanup in `finally`,
including on failure. Tests against an existing environment remove only their
three test containers; finish with `Stop` to remove its disposable test volume and
the persistent development environment. Do not edit its frozen ownership files.
Images are pinned by digest in Compose/Dockerfiles. Containers have bounded CPU
and memory, no automatic restart, and no Docker socket. The default profile has
no host credential or source mounts. QQQ/test containers additionally cap process
counts. Builds copy
only explicit source inputs. The QQQ and MinIO containers share their own network
namespace so the existing loopback S3 backend can be used without relaxing
production endpoint validation.
## What the results mean
- The S3 fixture exercises the actual QQQ `StorageAction`/S3 backend and AWS SDK,
including a multipart-size document, hashes, tenant namespaces, immutable
binding checks, retention and unrelated-object preservation.
- The workload uses the real current Java server and pinned Windows Core client:
native TABLE authentication, outbound WebSocket notifications, event-driven
claims, document downloads and durable server receipts. A fake file printer
verifies bytes and rejects duplicate submission. It cannot produce paper.
- The fixture's client receipt journal is intentionally simple; these results do
not replace production SQLite crash-recovery tests. MinIO fixture credentials
are unrestricted within their owned store and do not qualify production IAM.
- Connections here are HTTP/WebSocket on an isolated local project. TLS, provider
browser consent/renewal, real deployment policies and hardware behavior are
separate qualifications. No silent bearer-authentication fallback is added.
Results include `result.json`, `server-result.json`, `s3-results.json` and
`execution.json` under a run directory. The persistent host also writes
`qqq-result.json`; optional provider checks write `auth-result.json`. Treat raw
fixture logs as private even though only synthetic accounts/documents are used.
The client Dockerfile pins commit `346fc285198908c47a2ee9db7b9adb53d5e88c3c`
and verifies its source archive SHA-256 before compilation. The Java image builds
this checkout, including local edits, and records its Git HEAD for context; a Git
HEAD alone does not certify a dirty tree as that released artifact. This tooling
is outside the production JAR and adds no application runtime dependency.
## Script regressions
With Pester 5 installed, the initializer and cleanup-ownership tests need no Docker
or network. Disable Pester's unrelated registry fixture on Windows:
```powershell
$configuration = New-PesterConfiguration
$configuration.Run.Path = './dev/tests'
$configuration.Run.Exit = $true
$configuration.TestRegistry.Enabled = $false
Invoke-Pester -Configuration $configuration
```
## Experimental native sign-in
The separate [native sign-in profile](native-auth/README.md) builds an explicitly
selected upstream candidate and uses container-private TLS trust. It mounts only
public Caddy configuration and specific protected fixture input/result files.
It is opt-in, Test-only, and does not change the default released QQQ dependency.
## Continuous integration
Primary Gitea CI runs the shared Maven verification with `ui-demo` and the pinned
protocol fixtures. GitHub Actions were retired for this repository on 2026-11-04.
The checked-in `.github/workflows/development.yml` records the former Windows/Linux
Pester, image-build and Docker checks; it is not an active CI guarantee. The
commands above remain available for intentional local development checks.
Maven Checkstyle checks all four Java fixture sources in `dev/java` and
`dev/native-auth/java` during `validate`. They are deliberately **not** Maven
compiler sources: the ordinary fixture uses the released QQQ tuple, while the
native host compiles only inside its explicitly selected candidate image. Both
server Dockerfiles copy both roots before Maven runs so neither image can skip
this style coverage.
The ten C# fixture files share `dev/.editorconfig`, strict build properties,
central Meziantou 3.0.296, and committed package locks. The existing pinned SDK
image contains .NET SDK 10.0.403, matching `dev/global.json`. Both image builds
perform locked, vulnerability-audited restore, warnings-as-errors compilation,
formatter verification, and their nine or 12 fake-HTTP discovery cases. Core is
still built from the exact public source archive above; no stubs or runtime
package dependency were added. For a local build, supply the real pinned Core
DLL through `-p:CoreAssemblyPath=/absolute/path/Qqq.PrintAgent.Core.dll`.
The longer experimental native-auth profile is
an explicit local command; it is not silently substituted for the
released dependency baseline.
+140
View File
@@ -0,0 +1,140 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
<# .SYNOPSIS Checks the owned local QQQ development host without enrolling or printing. #>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$StateDirectory)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$state = [IO.Path]::GetFullPath($StateDirectory)
$owner = Get-Content -LiteralPath (Join-Path $state 'owner.json') -Raw | ConvertFrom-Json
if ($owner.project -cnotmatch '^qbit-printing-dev-[a-f0-9]{32}$')
{
throw 'Expected an owned development environment.'
}
$values = @{}
foreach ($line in Get-Content -LiteralPath (Join-Path $state 'compose.env'))
{
if ($line -match '^([A-Z_]+)=(.*)$')
{
$values[$Matches[1]] = $Matches[2]
}
}
$port = [int]$values.QQQ_HTTP_PORT
if ($port -lt 1024 -or $port -gt 65535 -or $port -in @(18081, 18086))
{
throw 'Expected a separate development port.'
}
$origin = "http://127.0.0.1:$port"
$handler = [Net.Http.HttpClientHandler]::new()
$handler.UseProxy = $false
$handler.AllowAutoRedirect = $false
$handler.CookieContainer = [Net.CookieContainer]::new()
$client = [Net.Http.HttpClient]::new($handler)
$client.BaseAddress = [Uri]$origin
$client.Timeout = [TimeSpan]::FromSeconds(15)
$client.MaxResponseContentBufferSize = 1048576
$client.DefaultRequestHeaders.Add('X-QQQ-Client', 'api/printing-development-check')
$client.DefaultRequestHeaders.Add('Origin', $origin)
$loggedIn = $false
function Invoke-DevelopmentRequest([string]$Path, [string]$Method = 'GET', [string]$Authorization)
{
<# .SYNOPSIS Sends one bounded native request, without returning credentials. #>
$request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $Path)
if ($Method -eq 'POST')
{
$request.Content = [Net.Http.StringContent]::new('{}', [Text.Encoding]::UTF8, 'application/json')
}
if ($Authorization)
{
$request.Headers.TryAddWithoutValidation('Authorization', $Authorization) | Out-Null
}
$response = $null
try
{
$response = $client.SendAsync($request).GetAwaiter().GetResult()
if (!$response.IsSuccessStatusCode)
{
throw 'Owned native endpoint returned an unsuccessful status.'
}
return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
finally
{
if ($null -ne $response)
{
$response.Dispose()
}; $request.Dispose()
}
}
try
{
$deadline = [DateTime]::UtcNow.AddSeconds(90)
do
{
try
{
$version = (Invoke-DevelopmentRequest '/qqq/v1/version') | ConvertFrom-Json; break
}
catch [Net.Http.HttpRequestException]
{
if ([DateTime]::UtcNow -ge $deadline)
{
throw
}; Start-Sleep -Seconds 1
}
} while ($true)
if (@($version.extensions | Where-Object { $_.name -ceq 'printing' -and $_.version -ceq '2.0.0' }).Count -ne 1)
{
throw 'Expected current printing extension.'
}
$metadata = (Invoke-DevelopmentRequest '/qqq/v1/metaData/authentication') | ConvertFrom-Json
if ($metadata.type -cne 'TABLE_BASED')
{
throw 'Development host must retain standard TABLE authentication.'
}
$basic = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('developer:' + $values.QQQ_ADMIN_PASSWORD))
try
{
$null = Invoke-DevelopmentRequest -Path '/qqq/v1/manageSession' -Method POST -Authorization $basic
}
finally
{
$basic = $null; $values = $null
}
$cookies = $handler.CookieContainer.GetCookies([Uri]$origin)
$cookie = $cookies['sessionId']; if ($null -eq $cookie)
{
$cookie = $cookies['sessionUUID']
}
$loggedIn = $null -ne $cookie
if (!$loggedIn -or !$cookie.HttpOnly)
{
throw 'Native HttpOnly session was not established.'
}
$identity = (Invoke-DevelopmentRequest '/qqq/v1/qbit/printing/session') | ConvertFrom-Json
if ($identity.principalId -cne 'developer' -or $identity.tenantId -cne 'development')
{
throw 'Native development identity mismatch.'
}
$html = Invoke-DevelopmentRequest '/'
if (!$html.Contains('<html', [StringComparison]::OrdinalIgnoreCase))
{
throw 'Native frontend asset missing.'
}
$null = Invoke-DevelopmentRequest -Path '/qqq/v1/logout' -Method POST
$loggedIn = $false
@{passed = $true; nativeTableLogin = $true; principalAndTenantVerified = $true; frontendHtml = $true; logout = $true; workSubmitted = $false; observedUtc = [DateTime]::UtcNow.ToString('O') } |
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'qqq-result.json')
Write-Output 'Owned QQQ native login, identity, frontend asset and logout passed; no work submitted.'
}
finally
{
if ($loggedIn)
{
$null = Invoke-DevelopmentRequest -Path '/qqq/v1/logout' -Method POST
}
$client.Dispose()
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
namespace Printing.Development;
/// <summary>Exercises fixture compatibility decisions using credential-free fake HTTP responses.</summary>
internal static class DiscoveryChecks
{
private static readonly string[] s_features = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
private static readonly string[] s_channels = ["api"];
/// <summary>Verify supported stable versions and reject malformed or incompatible versions.</summary>
internal static async Task RunAsync()
{
(string Version, bool Accepted)[] cases =
[
("2.0.0", true), ("2.1.0", true), ("2.9.17", true),
("1.9.0", false), ("3.0.0", false), ("2.1", false),
("2.01.0", false), ("2.1.0-rc.1", false), ("not-a-version", false)
];
foreach((string version, bool expected) in cases)
{
using var handler = new DiscoveryHandler(version);
using var client = new QqqSessionClient(new Uri("https://printing.example.invalid"), handler);
bool accepted = false;
try
{
await WorkloadDiscovery.DiscoverRealtimeAsync(client, CancellationToken.None);
accepted = true;
}
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
{
// Expected for incompatible versions: the case stays rejected and the comparison below judges it.
}
catch(InvalidDataException)
{
// Expected for malformed versions: the case stays rejected and the comparison below judges it.
}
if(accepted != expected || handler.Requests != 1)
{
throw new InvalidDataException($"Discovery compatibility case {version} failed.");
}
}
Console.WriteLine($"Discovery compatibility checks passed: {cases.Length}.");
}
/// <summary>Supply one version response and reject any premature credential transmission.</summary>
private sealed class DiscoveryHandler(string version) : HttpMessageHandler
{
internal int Requests
{
get;
private set;
}
/// <summary>Return only the synthetic discovery response and count the request.</summary>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if(request.Method != HttpMethod.Get
|| !string.Equals(request.RequestUri?.AbsolutePath, "/qqq/v1/version", StringComparison.Ordinal)
|| request.Headers.Contains("Cookie")
|| request.Headers.Contains("Authorization"))
{
throw new InvalidOperationException("Discovery must precede authentication.");
}
Requests++;
string json = JsonSerializer.Serialize(new
{
apiVersion = "1.8.0",
features = s_features,
clientChannels = s_channels,
extensions = new[] { new { name = "printing", version, pathPrefix = "/qbit/printing" } }
});
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
}
}
}
+625
View File
@@ -0,0 +1,625 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics;
using System.Globalization;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
namespace Printing.Development;
/// <summary>Runs real Core network workers against synthetic Java services without a physical printer.</summary>
internal static class Program
{
private static readonly JsonSerializerOptions s_reportOptions = new() { WriteIndented = true };
private static readonly string[] s_capabilities = ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL"];
private static int s_completed;
private static readonly byte[] s_expected = Encoding.ASCII.GetBytes("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS" + new string(' ', 980) + "^XZ");
/// <summary>Run the bounded workload or fake-HTTP checks and always signal completion to the launcher.</summary>
private static async Task<int> Main(string[] args)
{
if(args is ["--check-discovery"])
{
await DiscoveryChecks.RunAsync();
return 0;
}
string folder = Path.GetFullPath(args[0]);
string stage = "input";
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(18));
List<Lane> lanes = [];
long peak = 0;
var timer = Stopwatch.StartNew();
try
{
using var input = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(folder, "input.json")));
stage = "native_login_and_channels";
bool idle = input.RootElement.GetProperty("idle").GetBoolean();
int perLane = input.RootElement.GetProperty("jobsPerLane").GetInt32();
int total = perLane * 8;
await OpenLanesAsync(input.RootElement, lanes, folder, perLane, timeout.Token);
bool denied = await RejectForeignProofAsync(input.RootElement, timeout.Token);
stage = "workload";
peak = await RunLanesAsync(lanes, folder, total, timeout.Token);
stage = "logout";
foreach(Lane lane in lanes)
{
await lane.Client.SignOutAsync(timeout.Token);
}
await WriteSuccessAsync(folder, lanes, total, idle, denied, peak, timer.Elapsed.TotalSeconds);
Console.WriteLine("PASS real Core/Java network lifecycles; fake printer only");
return 0;
}
catch(Exception error)
{
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(new
{
success = false,
stage,
error = error.GetType().Name,
reason = error is InvalidDataException ? error.Message : null,
completed = lanes.Sum(l => l.Printer.Count)
}));
Console.Error.WriteLine("FAIL " + stage + ": " + error.GetType().Name);
return 1;
}
finally
{
foreach(Lane lane in lanes)
{
await lane.DisposeAsync();
}
await File.WriteAllTextAsync(Path.Combine(folder, "done"), "done");
}
}
/// <summary>Authenticate and bind eight synthetic lanes before admitting fixture work.</summary>
private static async Task OpenLanesAsync(JsonElement input, List<Lane> lanes, string folder, int perLane, CancellationToken token)
{
int index = 0;
foreach(JsonElement value in input.GetProperty("lanes").EnumerateArray())
{
Uri origin = new(value.GetProperty("origin").GetString()!);
Require(origin.IsLoopback && origin.Port is not (18081 or 18086), "disposable origin");
var client = new QqqSessionClient(origin, allowLoopbackHttp: true);
await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
await client.GetAuthenticationMetadataAsync(token);
await client.SignInWithPasswordAsync(value.GetProperty("username").GetString()!, value.GetProperty("password").GetString()!, token);
await client.BindDeviceAsync(new(value.GetProperty("deviceId").GetString()!, value.GetProperty("deviceToken").GetString()!), cancellationToken: token);
QqqSessionBinding binding = await client.GetSessionIdentityAsync(token);
Require(string.Equals(binding.TenantId, value.GetProperty("tenant").GetString(), StringComparison.Ordinal), "authoritative tenant");
Connection connection = await Connection.Open(client, token);
lanes.Add(new(client, connection, Path.Combine(folder, "lane-" + index++), perLane, folder));
}
Require(lanes.Count == 8, "eight lanes");
}
/// <summary>Verify that authenticated tenant A cannot adopt tenant B's device proof.</summary>
private static async Task<bool> RejectForeignProofAsync(JsonElement input, CancellationToken token)
{
// Authoritative native proof check: tenant A cannot bind a real tenant B device.
JsonElement foreign = input.GetProperty("lanes")[4];
bool denied = false;
using(var outsider = new QqqSessionClient(new Uri(input.GetProperty("lanes")[0].GetProperty("origin").GetString()!), allowLoopbackHttp: true))
{
await outsider.DiscoverAsync(token);
await outsider.GetAuthenticationMetadataAsync(token);
await outsider.SignInWithPasswordAsync("operator", input.GetProperty("lanes")[0].GetProperty("password").GetString()!, token);
try
{
await outsider.BindDeviceAsync(new(foreign.GetProperty("deviceId").GetString()!, foreign.GetProperty("deviceToken").GetString()!), cancellationToken: token);
}
catch(QqqSessionException) { denied = true; }
Require(denied, "cross tenant proof refused");
await outsider.SignOutAsync(token);
}
return denied;
}
/// <summary>Write only aggregate network, accounting, and fake-printer evidence.</summary>
private static async Task WriteSuccessAsync(string folder, List<Lane> lanes, int total, bool idle, bool denied, long peak, double elapsedSeconds)
{
double[] latencies = lanes.SelectMany(l => l.Latencies).Order().ToArray();
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(new
{
success = true,
jobs = total,
idle,
tenants = 2,
devices = 8,
elapsedSeconds = elapsedSeconds,
jobsPerSecond = total / elapsedSeconds,
peakClientWorkingSetBytes = peak,
p50EnqueueToAckMillis = latencies[total / 2],
p95EnqueueToAckMillis = latencies[(int)(total * 0.95)],
p99EnqueueToAckMillis = latencies[(int)(total * 0.99)],
realCoreWorker = true,
fakePrinterBackend = true,
fixtureJournal = true,
serverReceiptsDurable = true,
crossTenantProofRefused = denied,
submissionReplays = idle ? 0 : 400,
receiptReplays = idle ? 0 : 400,
notificationToClaimMillis = lanes.SelectMany(l => l.ClaimLatency).ToArray(),
notificationToDownloadCompletedMillis = lanes.SelectMany(l => l.DownloadLatency).ToArray(),
periodicJobPolling = false,
transport = "HTTP/WS loopback; no TLS qualification",
realPrinterCalls = 0,
runtime = Environment.Version.ToString(),
coreVersion = typeof(PrintWorker).Assembly.GetName().Version?.ToString()
}, s_reportOptions));
}
/// <summary>Drain only event-triggered work and measure the peak client working set.</summary>
private static async Task<long> RunLanesAsync(List<Lane> lanes, string folder, int total, CancellationToken token)
{
long peak = 0;
Task[] tasks = lanes.Select(lane => lane.Run(token)).ToArray();
await File.WriteAllTextAsync(Path.Combine(folder, "ready"), "ready", token);
var all = Task.WhenAll(tasks);
while(!all.IsCompleted)
{
peak = Math.Max(peak, Process.GetCurrentProcess().WorkingSet64);
await Task.WhenAny(all, Task.Delay(1000, token));
token.ThrowIfCancellationRequested();
}
await all;
Require(lanes.Sum(l => l.Printer.Count) == total, "exact fake submission count");
Require(lanes.All(l => l.Journal.Pending().Count == 0), "no unacknowledged receipts");
return peak;
}
/// <summary>Stop the fixture when an invariant fails, retaining a fixed diagnostic reason.</summary>
private static void Require(bool ok, string reason)
{
if(!ok)
{
throw new InvalidDataException(reason);
}
}
/// <summary>Own one authenticated worker, its synthetic journal, and notification latency measurements.</summary>
private sealed class Lane : IAsyncDisposable
{
public QqqSessionClient Client
{
get;
}
public Connection Connection
{
get;
}
public FakePrinter Printer { get; } = new();
public Journal Journal
{
get;
}
public List<double> Latencies { get; } = [];
private readonly PrintApiClient _api;
private readonly PrintWorker _worker;
private readonly int _target;
private readonly string _root;
private long _wakeAt;
public List<double> ClaimLatency { get; } = [];
public List<double> DownloadLatency { get; } = [];
/// <summary>Own one authenticated worker, its synthetic journal, and notification latency measurements.</summary>
public Lane(QqqSessionClient client, Connection connection, string folder, int targetJobs, string rootDirectory)
{
Client = client;
Connection = connection;
_target = targetJobs;
_root = rootDirectory;
Printer.OnDownload = () => DownloadLatency.Add(Stopwatch.GetElapsedTime(_wakeAt).TotalMilliseconds);
Directory.CreateDirectory(folder);
Journal = new(folder);
_api = PrintApiClient.ForSession(client).WithRealtime(connection.Channel, connection.Settings);
_worker = new PrintWorker(_api, Printer, Journal, options: new PrintWorkerOptions
{
Settings = connection.Settings,
StagingDirectory = Path.Combine(folder, "staging"),
SynchronizeInventory = false,
RecoverIncomplete = false,
ActiveJobChanged = job =>
{
if(job is not null)
{
ClaimLatency.Add(Stopwatch.GetElapsedTime(_wakeAt).TotalMilliseconds);
}
}
});
}
/// <summary>Drain notified jobs and await durable receipts without periodic job polling.</summary>
public async Task Run(CancellationToken token)
{
using var stop = CancellationTokenSource.CreateLinkedTokenSource(token);
Task ping = Liveness(stop.Token);
try
{
while(Printer.Count < _target)
{
_wakeAt = await Connection.Wakes.Reader.ReadAsync(token);
while(Printer.Count < _target && await _worker.RunOnceAsync(token))
{
Require(string.Equals(Journal.Last.Event.Status, "SPOOLED", StringComparison.Ordinal), "synthetic job terminal status");
Latencies.Add((DateTimeOffset.UtcNow - DateTimeOffset.Parse(Journal.LastJob!.CreatedAt, CultureInfo.InvariantCulture)).TotalMilliseconds);
await File.WriteAllTextAsync(Path.Combine(_root, "completed-" + Interlocked.Increment(ref s_completed)), "acknowledged", token);
if(Printer.Count % 25 == 0)
{
await _api.EventAsync(Journal.Last.JobId, Journal.Last.Event, token);
}
}
}
}
finally
{
await stop.CancelAsync();
try
{
await ping;
}
catch(OperationCanceledException)
{
// Cancellation ends only the owned liveness task.
}
}
}
/// <summary>Send transport liveness frames without querying for jobs.</summary>
private async Task Liveness(CancellationToken token)
{
using var tick = new PeriodicTimer(TimeSpan.FromSeconds(Connection.Settings.HeartbeatIntervalSeconds));
while(await tick.WaitForNextTickAsync(token))
{
await Connection.Channel.SendAsync("PING", new
{
}, token: token);
}
}
/// <summary>Stop owned background work and dispose this fixture lane or channel.</summary>
public async ValueTask DisposeAsync()
{
_api.Dispose();
await Connection.DisposeAsync();
Client.Dispose();
Journal.Dispose();
}
}
/// <summary>Verify payload bytes and reject duplicate actions without calling a spooler.</summary>
private sealed class FakePrinter : IFilePrinterBackend
{
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
public int Count => _seen.Count;
public Action? OnDownload
{
get; set;
}
/// <summary>Expose only the fixed synthetic ZPL destination.</summary>
public IReadOnlyList<PrinterInfo> Discover() => [new("synthetic", "No physical printer", "fixture", "none", "READY", ["ZPL"])];
/// <summary>Record download completion and validate the staged synthetic payload.</summary>
public async Task<string> PrintFileAsync(PrintJob job, string path, CancellationToken token)
{
OnDownload?.Invoke();
return await PrintAsync(job, await File.ReadAllBytesAsync(path, token), token);
}
/// <summary>Check exact bytes and allow each synthetic job to be submitted once.</summary>
public Task<string> PrintAsync(PrintJob job, byte[] content, CancellationToken token)
{
Require(content.AsSpan().SequenceEqual(s_expected), "downloaded bytes exact");
Require(_seen.Add(job.JobId), "no duplicate physical action");
return Task.FromResult("synthetic-" + Count);
}
}
// Fixture journal is deliberately separate from the installed Windows SQLite journal.
/// <summary>Track fixture-only receipt state separately from the installed SQLite journal.</summary>
private sealed class Journal : IJobJournal, IDisposable
{
private readonly Dictionary<string, string> _states = new(StringComparer.Ordinal);
private readonly Dictionary<string, (string, PrintEvent)> _pending = new(StringComparer.Ordinal);
private readonly StreamWriter _log;
public PrintJob? LastJob
{
get;
private set;
}
public (string JobId, PrintEvent Event) Last
{
get;
private set;
}
/// <summary>Track fixture-only receipt state separately from the installed SQLite journal.</summary>
public Journal(string folder)
{
_log = new StreamWriter(new FileStream(Path.Combine(folder, "synthetic-receipts.jsonl"), FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { AutoFlush = true };
}
/// <summary>Retain accepted job metadata for latency and status assertions.</summary>
public void TrackJob(PrintJob job, string? printer)
{
LastJob = job;
}
/// <summary>Return the last recorded state for the synthetic job.</summary>
public string? GetState(string id) => _states.GetValueOrDefault(id);
/// <summary>Flush a receipt before adding it to the pending set.</summary>
public void Record(string id, PrintEvent evt)
{
_log.WriteLine(JsonSerializer.Serialize(new
{
jobId = id,
@event = evt
}));
_states[id] = evt.Status;
_pending.Add(evt.EventId, (id, evt));
Last = (id, evt);
}
/// <summary>Snapshot unacknowledged synthetic receipts for the real worker.</summary>
public IReadOnlyList<(string JobId, PrintEvent Event)> Pending() => _pending.Values.ToList();
/// <summary>Remove only the specifically acknowledged receipt.</summary>
public void Acknowledge(string id)
{
_pending.Remove(id);
}
/// <summary>Declare no recovered jobs because each fixture journal starts fresh.</summary>
public IReadOnlyList<(string JobId, string State)> Incomplete() => [];
/// <summary>Close the fixture receipt log.</summary>
public void Dispose() => _log.Dispose();
}
/// <summary>Own the realtime reader, bounded notifications, and negotiated settings.</summary>
private sealed class Connection : IAsyncDisposable
{
public required PrintingRealtimeChannel Channel
{
get; init;
}
public required Task Reader
{
get; init;
}
public required CancellationTokenSource Lifetime
{
get; init;
}
public required Channel<RealtimeEnvelope> Messages
{
get; init;
}
public required Channel<long> Wakes
{
get; init;
}
public RealtimeSettings Settings
{
get;
private set;
} = new();
/// <summary>Open an authenticated channel and acknowledge its initial authoritative state.</summary>
public static async Task<Connection> Open(QqqSessionClient client, CancellationToken token)
{
var lifetime = CancellationTokenSource.CreateLinkedTokenSource(token);
WebSocket socket = await client.ConnectPrintingAsync(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(60), token);
var channel = new PrintingRealtimeChannel(socket, new RealtimeSettings());
var messages = System.Threading.Channels.Channel.CreateBounded<RealtimeEnvelope>(8);
var wakes = System.Threading.Channels.Channel.CreateBounded<long>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
Task reader = channel.RunAsync(e =>
{
if(string.Equals(e.Type, "JOB_AVAILABLE", StringComparison.Ordinal))
{
wakes.Writer.TryWrite(Stopwatch.GetTimestamp());
return;
}
if(e.Type is "SETTINGS" or "DESIRED_STATE")
{
long revision = e.Payload.GetProperty("revision").GetInt64();
if(revision != 0)
{
throw new InvalidDataException("Unexpected policy change");
}
return;
}
if(e.Type is "PONG" or "ACK")
{
return;
}
if(!messages.Writer.TryWrite(e))
{
throw new IOException("Fixture envelope inbox full");
}
}, lifetime.Token);
_ = reader.ContinueWith(t => { wakes.Writer.TryComplete(t.Exception); messages.Writer.TryComplete(t.Exception); }, TaskScheduler.Default);
var result = new Connection { Channel = channel, Reader = reader, Lifetime = lifetime, Messages = messages, Wakes = wakes };
await channel.SendAsync("HELLO", new
{
agentVersion = PrintApiClient.AgentVersion,
capabilities = s_capabilities,
lastAppliedRevision = 0,
lastSettingsRevision = 0,
limits = RealtimeSettings.ClientLimits
}, token: token);
RealtimeEnvelope welcome = await messages.Reader.ReadAsync(token);
Require(string.Equals(welcome.Type, "WELCOME", StringComparison.Ordinal), "welcome");
result.Settings = welcome.Payload.GetProperty("settings").Deserialize<RealtimeSettings>(PrintApiClient.JsonOptions)!;
result.Settings.Validate();
channel.UpdateSettings(result.Settings);
await channel.SendAsync("STATE_APPLIED", new
{
lastAppliedRevision = welcome.Payload.GetProperty("desiredState").GetProperty("revision").GetInt64(),
lastSettingsRevision = result.Settings.Revision
}, acknowledged: true, token: token);
return result;
}
/// <summary>Stop owned background work and dispose this fixture lane or channel.</summary>
public async ValueTask DisposeAsync()
{
await Lifetime.CancelAsync();
try
{
await Reader;
}
catch(Exception e) when(e is OperationCanceledException or IOException or WebSocketException)
{
// Cancelling the owned reader normally ends it with one of these; disposal must still finish.
}
Channel.Dispose();
Lifetime.Dispose();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<!--
Copyright 2026 Kingsrook, LLC
SPDX-License-Identifier: Apache-2.0
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Reference Include="Qqq.PrintAgent.Core">
<HintPath>$(CoreAssemblyPath)</HintPath>
</Reference>
</ItemGroup>
</Project>
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using Qqq.PrintAgent.Core.Authentication;
namespace Printing.Development;
/// <summary>Applies production Core discovery rules before any fixture credentials are sent.</summary>
internal static class WorkloadDiscovery
{
/// <summary>Require a compatible realtime extension after strict production discovery validation.</summary>
internal static async Task<QqqApiDiscovery> DiscoverRealtimeAsync(QqqSessionClient client, CancellationToken cancellationToken)
{
QqqApiDiscovery discovery = await client.DiscoverAsync(cancellationToken);
// Core discovery validates strict stable SemVer; realtime uses the same major gate as ConnectPrintingAsync.
QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal));
if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2)
{
throw new InvalidDataException("Native realtime printing API required.");
}
return discovery;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
}
}
}
}
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Optional real provider reference. No Docker socket or published database port.
services:
authentik-postgres:
profiles: [auth]
platform: linux/amd64
image: postgres@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:?Run the development initializer first}
volumes:
- authentik-database:/var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, "pg_isready -U authentik -d authentik"]
interval: 5s
timeout: 5s
retries: 30
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
mem_limit: 512m
cpus: 1.0
authentik-server:
profiles: [auth]
platform: linux/amd64
image: ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
command: server
depends_on:
authentik-postgres:
condition: service_healthy
environment: &authentik-environment
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:?Run the development initializer first}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?Run the development initializer first}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
AUTHENTIK_LOG_LEVEL: warning
ports:
- "127.0.0.1:${AUTHENTIK_HTTP_PORT:-19010}:9000"
volumes:
- authentik-data:/data
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
shm_size: 256mb
mem_limit: 1536m
cpus: 2.0
authentik-worker:
profiles: [auth]
platform: linux/amd64
image: ghcr.io/goauthentik/server@sha256:602227a86455d6772e3d16b7d852713c4f864f40df5df894abd1285bb53979a5
command: worker
depends_on:
authentik-postgres:
condition: service_healthy
environment:
<<: *authentik-environment
AUTHENTIK_BOOTSTRAP_PASSWORD_HASH: ${AUTHENTIK_BOOTSTRAP_PASSWORD_HASH:?Run the development initializer first}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:?Run the development initializer first}
AUTHENTIK_BOOTSTRAP_EMAIL: developer@example.invalid
volumes:
- authentik-data:/data
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
shm_size: 256mb
mem_limit: 1536m
cpus: 2.0
volumes:
authentik-database:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
authentik-data:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?Run the development initializer first}
+78
View File
@@ -0,0 +1,78 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Opt-in unpublished core qualification. No service is a replacement for the
# released RC.20 TABLE development host or an installed printing deployment.
services:
authentik-server:
networks:
default:
aliases:
- auth.printing.internal
- qqq.printing.internal
- callback.printing.internal
native-tls:
profiles: [native-auth]
image: caddy@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
platform: linux/amd64
network_mode: service:authentik-server
depends_on: [authentik-server]
volumes:
- ${DEV_SOURCE_DIR}/dev/native-auth/Caddyfile:/etc/caddy/Caddyfile:ro
- ${DEV_SOURCE_DIR}/dev/native-auth/copy-root.sh:/etc/caddy/copy-root.sh:ro
- native-ca:/data
- native-trust:/trust
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
cpus: 1
mem_limit: 256m
native-qqq:
profiles: [native-auth]
build:
context: ${DEV_SOURCE_DIR}
dockerfile: dev/native-auth/Dockerfile.server
image: ${PRINTING_DEV_PROJECT}-native-server:local
environment:
NATIVE_QQQ_PORT: "19082"
NATIVE_QQQ_ORIGIN: https://qqq.printing.internal
NATIVE_QQQ_DATABASE_PASSWORD: ${NATIVE_QQQ_DATABASE_PASSWORD:?Initialize experimental state first}
NATIVE_QQQ_REFRESH_KEY: ${NATIVE_QQQ_REFRESH_KEY:?Initialize experimental state first}
NATIVE_AUTH_ISSUER: ${NATIVE_AUTH_ISSUER:?Initialize experimental state first}
NATIVE_AUTH_CLIENT_ID: ${NATIVE_AUTH_CLIENT_ID:?Initialize experimental state first}
NATIVE_AUTH_CLIENT_SECRET: ${NATIVE_AUTH_CLIENT_SECRET:?Initialize experimental state first}
NATIVE_AUTH_REDIRECT_URI: https://callback.printing.internal/oauth/callback
volumes:
- native-trust:/trust:ro
- native-qqq-data:/data
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
cpus: 2
mem_limit: 1g
native-client:
profiles: [native-auth]
build:
context: ${DEV_SOURCE_DIR}
dockerfile: dev/native-auth/Dockerfile.client
image: ${PRINTING_DEV_PROJECT}-native-client:local
volumes:
- native-trust:/trust:ro
- ${NATIVE_AUTH_INPUT_FILE:?Initialize experimental state first}:/work/input.json:ro
- ${NATIVE_AUTH_RESULT_FILE:?Initialize experimental state first}:/work/result.json
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
cpus: 2
mem_limit: 512m
volumes:
native-ca:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
native-trust:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
native-qqq-data:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
+108
View File
@@ -0,0 +1,108 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Every published port is bound to host loopback; no live printing configuration is imported.
x-owned: &owned
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT:?owned project required}
restart: "no"
cpus: 2
mem_limit: 1g
pids_limit: 256
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=128m,mode=1777"]
services:
minio:
<<: *owned
image: cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56
user: "0:0"
command: [server, /data, --address, ":9000"]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?fixture secret required}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?fixture secret required}
volumes: [minio-data:/data]
ports:
- "127.0.0.1:${MINIO_HTTP_PORT:-19095}:9000"
- "127.0.0.1:${QQQ_HTTP_PORT:-19081}:${QQQ_HTTP_PORT:-19081}"
qqq:
<<: *owned
build:
context: ${DEV_SOURCE_DIR:?source required}
dockerfile: dev/Dockerfile.server
args:
SOURCE_COMMIT: ${SOURCE_COMMIT:?source identity required}
image: ${PRINTING_DEV_PROJECT}-server:local
network_mode: service:minio
depends_on: [minio]
environment:
QQQ_HTTP_PORT: ${QQQ_HTTP_PORT:-19081}
QQQ_DATABASE_PASSWORD: ${QQQ_DATABASE_PASSWORD:?fixture secret required}
QQQ_ADMIN_PASSWORD: ${QQQ_ADMIN_PASSWORD:?fixture secret required}
QQQ_MANAGEMENT_TOKEN: ${QQQ_MANAGEMENT_TOKEN:?fixture secret required}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes: [qqq-data:/data]
minio-test:
<<: *owned
profiles: [test]
image: cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56
user: "0:0"
command: [server, /data, --address, ":9000"]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m,mode=1777", "/data:rw,noexec,nosuid,size=900m,mode=1777"]
test-server:
<<: *owned
profiles: [test]
build:
context: ${DEV_SOURCE_DIR}
dockerfile: dev/Dockerfile.server
args:
SOURCE_COMMIT: ${SOURCE_COMMIT}
image: ${PRINTING_DEV_PROJECT}-server:local
network_mode: service:minio-test
depends_on: [minio-test]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
TEST_RUN: ${TEST_RUN:-run-uninitialized}
WORKLOAD_MODE: ${WORKLOAD_MODE:-idle}
volumes: [test-data:/work]
entrypoint: [/bin/sh, /app/run-tests.sh]
healthcheck:
test: [CMD-SHELL, 'test -f "/work/$$TEST_RUN/input.json"']
interval: 1s
timeout: 2s
retries: 120
test-client:
<<: *owned
profiles: [test]
build:
context: ${DEV_SOURCE_DIR}
dockerfile: dev/Dockerfile.client
image: ${PRINTING_DEV_PROJECT}-client:local
network_mode: service:minio-test
depends_on:
test-server:
condition: service_healthy
volumes: [test-data:/work]
command: ["/work/${TEST_RUN:-run-uninitialized}"]
volumes:
minio-data:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
qqq-data:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
test-data:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
networks:
default:
labels:
io.qrun.printing.dev: ${PRINTING_DEV_PROJECT}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.403",
"rollForward": "latestPatch"
}
}
+174
View File
@@ -0,0 +1,174 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// SPDX-License-Identifier: Apache-2.0
package com.kingsrook.qbits.printing;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
/***************************************************************************
** Docker-only developer host. Compose publishes its port to host loopback only.
***************************************************************************/
public final class DevHost extends PrintingLocalHost
{
private static final String TABLE = "printing_documents";
/***************************************************************************
** Configure the isolated developer database and synthetic account from its private environment.
***************************************************************************/
private DevHost() throws Exception
{
super(new PrintingLocalSettings("jdbc:h2:file:/data/printing", "sa", required("QQQ_DATABASE_PASSWORD"), "development",
Integer.parseInt(required("QQQ_HTTP_PORT")), required("QQQ_MANAGEMENT_TOKEN"), "developer", required("QQQ_ADMIN_PASSWORD")));
}
/***************************************************************************
** Wait for owned storage and launch the loopback-published developer host.
***************************************************************************/
public static void main(String[] args) throws Exception
{
waitStorage();
if(args.length == 1 && "--wait-storage".equals(args[0]))
{
return;
}
DevHost host = new DevHost();
QApplicationLauncher.run(host, new QApplicationLauncherConfig().withServerCustomizer(server ->
{
host.configure(server, true);
// Container interface only. The immutable Compose mapping binds host 127.0.0.1.
var original = server.getJavalinConfigCustomizer();
server.withJavalinConfigCustomizer(config ->
{
original.accept(config);
config.jetty.host = "0.0.0.0";
});
}));
}
/***************************************************************************
** Add the isolated MinIO-backed document table and make sure its bucket exists.
***************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = super.defineQInstance();
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_development_storage").withBucketName("printing-development")
.withEndpoint("http://127.0.0.1:9000").withPathStyleAccess(true).withRegion("us-east-1")
.withAccessKey(required("MINIO_ROOT_USER")).withSecretKey(required("MINIO_ROOT_PASSWORD"));
backend.setConnectTimeoutMillis(3000);
backend.setSocketTimeoutMillis(15000);
instance.addBackend(backend);
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE).withFileNameFieldName("id").withContentsFieldName("content"))
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
var storage = S3ClientFactory.getClient(backend);
try
{
storage.headBucket(HeadBucketRequest.builder().bucket("printing-development").build());
}
catch(S3Exception missing)
{
if(missing.statusCode() != 404)
{
throw missing;
}
storage.createBucket(CreateBucketRequest.builder().bucket("printing-development").build());
}
PrintingQBitConfig config = instance.getQBits().values().stream().map(value -> value.getConfig())
.filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow();
config.withStorageTableName(TABLE);
config.getService().configureStorage(instance, TABLE);
return instance;
}
/***************************************************************************
** Bound startup retries to the owned MinIO health endpoint.
***************************************************************************/
private static void waitStorage() throws Exception
{
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build())
{
long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos();
while(System.nanoTime() < deadline)
{
try
{
if(client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:9000/minio/health/live"))
.timeout(Duration.ofSeconds(2)).GET().build(), HttpResponse.BodyHandlers.discarding()).statusCode() == 200)
{
return;
}
}
catch(IOException notReady)
{
// Only the fixture startup window retries; application work is never retried here.
}
Thread.sleep(200);
}
throw new IllegalStateException("Owned MinIO startup timed out");
}
}
/***************************************************************************
** Read a required fixture setting without exposing its value.
***************************************************************************/
private static String required(String name)
{
String value = System.getenv(name);
if(value == null || value.isBlank())
{
throw new IllegalArgumentException("Missing development environment setting: " + name);
}
return value;
}
}
+441
View File
@@ -0,0 +1,441 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// SPDX-License-Identifier: Apache-2.0
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qbits.printing.DriverDataSource;
import com.kingsrook.qbits.printing.PrintingException;
import com.kingsrook.qbits.printing.PrintingService;
import com.kingsrook.qbits.printing.PrintingTestDocuments;
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.QAuthenticationMetaData;
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.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3TableBackendDetails;
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
import org.json.JSONArray;
import org.json.JSONObject;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.ListMultipartUploadsRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.model.S3Object;
/***************************************************************************
** Non-listening synthetic harness. Never routes work to a Windows device.
***************************************************************************/
public final class S3PrintingQualification
{
private static final String TABLE = "printing_documents";
private static final JSONArray CHECKS = new JSONArray();
private static final JSONArray DOCUMENTS = new JSONArray();
private static final Map<String, String> OBJECT_KEYS = new HashMap<>();
private static DriverDataSource database;
private static S3Client s3;
private static String bucket;
private static String otherBucket;
/***************************************************************************
** Exercise real storage operations against dedicated synthetic buckets without dispatching to a printer.
***************************************************************************/
public static void main(String[] args) throws Exception
{
Path output = Path.of(args[0]).toAbsolutePath();
Files.createDirectories(output);
String run = args[2];
bucket = "printing-fixture-" + run;
otherBucket = "printing-canary-" + run;
S3BackendMetaData backend = new S3BackendMetaData().withName("printing_s3_" + run)
.withBucketName(bucket).withEndpoint(args[1]).withPathStyleAccess(true).withRegion("us-east-1")
.withAccessKey(System.getenv("MINIO_ROOT_USER")).withSecretKey(System.getenv("MINIO_ROOT_PASSWORD"));
backend.setConnectTimeoutMillis(3000);
backend.setSocketTimeoutMillis(15000);
QInstance instance = new QInstance();
// Required metadata only: this harness opens no HTTP endpoint or user session.
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
instance.addBackend(backend);
instance.addTable(new QTableMetaData().withName(TABLE).withBackendName(backend.getName())
.withBackendDetails(new S3TableBackendDetails().withCardinality(Cardinality.ONE)
.withFileNameFieldName("id").withContentsFieldName("content")).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.STRING)).withField(new QFieldMetaData("content", QFieldType.BLOB)));
database = new DriverDataSource("jdbc:h2:file:" + output.resolve("synthetic-data").toString().replace('\\', '/'), "sa", "");
try
{
s3 = S3ClientFactory.getClient(backend);
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
s3.createBucket(CreateBucketRequest.builder().bucket(otherBucket).build());
s3.putObject(PutObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build(), RequestBody.fromString("synthetic unrelated object"));
s3.putObject(PutObjectRequest.builder().bucket(otherBucket).key("canary.txt").build(), RequestBody.fromString("synthetic separate bucket"));
PrintingService a = service(instance, "fixture-tenant-a");
PrintingService b = service(instance, "fixture-tenant-b");
String deviceA = enroll(a);
String deviceB = enroll(b);
byte[] pdf = PrintingTestDocuments.pdf();
byte[] zpl = "^XA^FO20,20^ADN,30,18^FDSYNTHETIC STORAGE TEST ONLY^FS^XZ".getBytes(StandardCharsets.US_ASCII);
byte[] large = new byte[6 * 1024 * 1024 + 71];
Arrays.fill(large, (byte) 'A');
System.arraycopy("^XA^FX".getBytes(StandardCharsets.US_ASCII), 0, large, 0, 6);
System.arraycopy("^FS^XZ".getBytes(StandardCharsets.US_ASCII), 0, large, large.length - 6, 6);
String pdfJob = submitCheck(a, deviceA, "small-pdf", "PDF", pdf, false);
finish(a, deviceA, pdfJob, "SPOOLED");
String zplJob = submitCheck(a, deviceA, "small-zpl", "ZPL", zpl, false);
finish(a, deviceA, zplJob, "SPOOLED");
String largeJob = submitCheck(a, deviceA, "multipart-zpl", "ZPL", large, true);
finish(a, deviceA, largeJob, "SPOOLED");
String foreignJob = submitCheck(b, deviceB, "foreign-zpl", "ZPL", zpl, false);
finish(b, deviceB, foreignJob, "SPOOLED");
String foreignRef = reference(foreignJob);
String ownRef = reference(pdfJob);
check(!ownRef.split("/")[1].equals(foreignRef.split("/")[1]), "tenant prefixes differ");
expectStatus(404, () -> a.content(deviceA, foreignJob), "foreign tenant job download denied");
expectStatus(404, () -> a.content(deviceB, foreignJob), "foreign tenant device rejected");
execute("UPDATE printing_job SET content_reference=? WHERE id=?", foreignRef, pdfJob);
expectStatus(409, () -> a.content(deviceA, pdfJob), "foreign stored reference rejected");
execute("UPDATE printing_job SET content_reference=? WHERE id=?", ownRef, pdfJob);
check(Arrays.equals(zpl, b.content(deviceB, foreignJob)), "foreign object unchanged after rejection");
String failed = submitCheck(a, deviceA, "protected-failed", "ZPL", zpl, false);
finish(a, deviceA, failed, "FAILED");
String unknown = submitCheck(a, deviceA, "protected-unknown", "ZPL", zpl, false);
finish(a, deviceA, unknown, "OUTCOME_UNKNOWN");
String active = a.submit(request(deviceA, "protected-queued", "ZPL", zpl)).getString("jobId");
String old = Instant.now().minusSeconds(400L * 86400).toString();
for(String id : List.of(pdfJob, largeJob, foreignJob, failed, unknown, active))
{
execute("UPDATE printing_job SET created_at=?,completed_at=? WHERE id=?", old, old, id);
}
int before = objectCount();
check(a.runRetention() == 2, "tenant A retention removes exactly two aged successful documents");
check(objectCount() == before - 2, "object count reflects exact two removals");
missing(OBJECT_KEYS.get(ownRef));
missing(OBJECT_KEYS.get(reference(largeJob)));
expectStatus(410, () -> a.content(deviceA, pdfJob), "expired document returns 410");
check(a.submit(request(deviceA, "small-pdf", "PDF", pdf)).getString("jobId").equals(pdfJob), "idempotent replay survives retention without reupload");
check(objectCount() == before - 2, "post-retention replay creates no object");
for(String id : List.of(zplJob, foreignJob, failed, unknown, active))
{
check(s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey(reference(id))).build()).contentLength() == zpl.length,
"protected object retained: " + status(id));
}
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key("unrelated/canary.txt").build()).asUtf8String().equals("synthetic unrelated object"), "unrelated same-bucket canary preserved");
check(s3.getObjectAsBytes(GetObjectRequest.builder().bucket(otherBucket).key("canary.txt").build()).asUtf8String().equals("synthetic separate bucket"), "separate bucket canary preserved");
check(a.runRetention() == 0, "repeated cleanup is idempotent");
check(b.runRetention() == 1, "tenant B cleanup independently removes its one eligible document");
check(s3.listMultipartUploads(ListMultipartUploadsRequest.builder().bucket(bucket).build()).uploads().isEmpty(), "no incomplete multipart uploads remain");
check(QContext.getQSession() == null, "storage restores original absent QQQ session");
JSONObject result = new JSONObject().put("passed", true).put("timestamp", Instant.now().toString())
.put("checks", CHECKS).put("documents", DOCUMENTS).put("runtimeJarCount", 204)
.put("appRevision", System.getenv("PRINTING_SOURCE_COMMIT"))
.put("h2Version", org.h2.engine.Constants.VERSION).put("retentionDays", a.getRetentionSettings().getInt("days"))
.put("transport", "loopback HTTP S3-compatible only").put("hardwarePrinting", false)
.put("qualifies", "QQQ StorageAction / AWS SDK v2 S3 data-plane behavior against disposable MinIO")
.put("doesNotQualify", new JSONArray(List.of("AWS IAM", "remote TLS", "production bucket ownership", "Object Lock/version lifecycle", "remote deployment", "hardware printing")));
Files.writeString(output.resolve("results.json"), result.toString(2) + "\n");
System.out.println("PASS " + CHECKS.length() + " checks; " + DOCUMENTS.length() + " synthetic document round trips");
}
finally
{
Files.writeString(output.resolve("completed-checks.json"), new JSONObject().put("checks", CHECKS).put("documents", DOCUMENTS).toString(2) + "\n");
S3ClientFactory.clearCache();
QContext.clear();
}
}
/***************************************************************************
** Initialize a tenant-scoped service using the shared synthetic storage metadata.
***************************************************************************/
private static PrintingService service(QInstance instance, String tenant) throws Exception
{
PrintingService service = new PrintingService(database, tenant);
service.initialize();
service.configureStorage(instance, TABLE);
return service;
}
/***************************************************************************
** Create and approve a synthetic storage-only destination.
***************************************************************************/
private static String enroll(PrintingService service) throws Exception
{
String id = service.enroll(service.createEnrollment("fixture", "Synthetic station")
.put("deviceName", "Synthetic station").put("agentVersion", "isolated-storage-fixture")).getString("deviceId");
service.inventory(id, new JSONArray().put(new JSONObject().put("printerId", "synthetic-never-dispatched")
.put("name", "Synthetic storage-only destination").put("status", "READY").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
service.enablePrinter(id, "synthetic-never-dispatched", true);
return id;
}
/***************************************************************************
** Build a repeatable submission containing synthetic document bytes.
***************************************************************************/
private static JSONObject request(String device, String key, String format, byte[] bytes)
{
return new JSONObject().put("deviceId", device).put("printerId", "synthetic-never-dispatched").put("title", "Synthetic storage qualification")
.put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
}
/***************************************************************************
** Verify submission replay, download integrity, and the actual external object representation.
***************************************************************************/
private static String submitCheck(PrintingService service, String device, String key, String format, byte[] bytes, boolean multipart) throws Exception
{
JSONObject request = request(device, key, format, bytes);
String id = service.submit(request).getString("jobId");
int count = objectCount();
check(id.equals(service.submit(request).getString("jobId")) && count == objectCount(), key + " stable replay creates no object");
check(service.claim(device).getJSONObject("job").getString("jobId").equals(id), key + " claimed through production service");
byte[] actual = service.content(device, id);
check(Arrays.equals(bytes, actual) && sha(bytes).equals(sha(actual)), key + " download bytes and SHA256 match");
String ref = reference(id);
String actualKey = objectKey(ref);
HeadObjectResponse head = s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(actualKey).build());
check(head.contentLength() == bytes.length, key + " object length exact");
check(head.contentType().equals(format.equals("PDF") ? "application/pdf" : "application/vnd.zebra-zpl"), key + " content type correct");
check(head.eTag().contains("-2") == multipart, key + " expected single/multipart object ETag");
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT content_base64,content_storage_table FROM printing_job WHERE id=?"))
{
statement.setString(1, id);
try(var rows = statement.executeQuery())
{
check(rows.next() && rows.getString(1) == null && TABLE.equals(rows.getString(2)), key + " payload external, no inline database copy");
}
}
DOCUMENTS.put(new JSONObject().put("case", key).put("format", format).put("bytes", bytes.length).put("sha256", sha(bytes)).put("multipart", multipart).put("etag", head.eTag())
.put("objectKeyEqualsStoredReference", actualKey.equals(ref)).put("objectKeyPrefixBeforeReference", actualKey.substring(0, actualKey.length() - ref.length())));
return id;
}
/***************************************************************************
** Record synthetic lifecycle events without invoking a physical spooler.
***************************************************************************/
private static void finish(PrintingService service, String device, String id, String status) throws Exception
{
for(String phase : List.of("RECEIVED", "SUBMITTING", status))
{
service.event(device, id, new JSONObject().put("eventId", UUID.randomUUID().toString()).put("status", phase).put("message", "Synthetic receipt; no physical device or spooler used"));
}
}
/***************************************************************************
** Compute the document digest used for round-trip assertions.
***************************************************************************/
private static String sha(byte[] bytes) throws Exception
{
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
}
/***************************************************************************
** Count objects in the dedicated synthetic bucket.
***************************************************************************/
private static int objectCount()
{
return s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().size();
}
/***************************************************************************
** Resolve and cache the physical key corresponding to a persisted document reference.
***************************************************************************/
private static String objectKey(String reference)
{
String known = OBJECT_KEYS.get(reference);
if(known != null)
{
return known;
}
List<String> matches = s3.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().stream()
.map(S3Object::key).filter(key -> key.endsWith(reference)).toList();
if(matches.size() != 1)
{
throw new AssertionError("Expected exactly one synthetic object for stored reference");
}
String key = matches.getFirst();
String prefix = key.substring(0, key.length() - reference.length());
if(!prefix.isEmpty() && !prefix.matches("\\\\+"))
{
throw new AssertionError("Unexpected physical S3 key prefix");
}
OBJECT_KEYS.put(reference, key);
return key;
}
/***************************************************************************
** Read the accepted external reference for a synthetic job.
***************************************************************************/
private static String reference(String id) throws Exception
{
return field(id, "content_reference");
}
/***************************************************************************
** Read the lifecycle state of a synthetic job.
***************************************************************************/
private static String status(String id) throws Exception
{
return field(id, "status");
}
/***************************************************************************
** Read a fixture-selected field from a synthetic job.
***************************************************************************/
private static String field(String id, String field) throws Exception
{
try(var c = database.getConnection(); var statement = c.prepareStatement("SELECT " + field + " FROM printing_job WHERE id=?"))
{
statement.setString(1, id);
try(var rows = statement.executeQuery())
{
if(!rows.next())
{
throw new AssertionError("Missing synthetic job");
}
return rows.getString(1);
}
}
}
/***************************************************************************
** Apply a bounded synthetic fixture adjustment with bound values.
***************************************************************************/
private static void execute(String sql, String... args) throws Exception
{
try(var c = database.getConnection(); var statement = c.prepareStatement(sql))
{
for(int i = 0; i < args.length; i++)
{
statement.setString(i + 1, args[i]);
}
statement.executeUpdate();
}
}
/***************************************************************************
** Record a successful assertion or stop qualification immediately.
***************************************************************************/
private static void check(boolean pass, String label)
{
if(!pass)
{
throw new AssertionError(label);
}
CHECKS.put(label);
}
/***************************************************************************
** Verify an eligible object is absent after retention.
***************************************************************************/
private static void missing(String ref)
{
try
{
s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(ref).build());
throw new AssertionError("Expected removed object");
}
catch(S3Exception exception)
{
check(exception.statusCode() == 404, "eligible object absent from S3 after cleanup");
}
}
/***************************************************************************
** Operation that can surface a checked storage or service failure.
***************************************************************************/
@FunctionalInterface
private interface Checked
{
/***************************************************************************
** Execute an operation whose expected HTTP-domain failure is checked by the caller.
***************************************************************************/
void run() throws Exception;
}
/***************************************************************************
** Require the exact domain rejection while allowing unrelated failures to surface.
***************************************************************************/
private static void expectStatus(int status, Checked operation, String label) throws Exception
{
try
{
operation.run();
throw new AssertionError(label);
}
catch(PrintingException exception)
{
check(exception.getStatus() == status, label);
}
}
}
+247
View File
@@ -0,0 +1,247 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// SPDX-License-Identifier: Apache-2.0
package com.kingsrook.qbits.printing;
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.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import io.javalin.Javalin;
import org.json.JSONArray;
import org.json.JSONObject;
/***************************************************************************
** Fixture only: real checkout host, synthetic SDK producer, no physical printer.
***************************************************************************/
public final class WorkloadFixture
{
/***************************************************************************
** Drive two synthetic tenants through real network workers and verify durable receipts and accounting.
***************************************************************************/
public static void main(String[] args) throws Exception
{
Path dir = Path.of(args[0]).toAbsolutePath();
if(!dir.getFileName().toString().startsWith("run-"))
{
throw new IllegalArgumentException("Owned run directory required");
}
String jdbc = "jdbc:h2:file:" + dir.resolve("synthetic").toString().replace('\\', '/') + ";DB_CLOSE_DELAY=-1";
String password = "synthetic-" + UUID.randomUUID();
List<QApplicationLauncher> launches = new ArrayList<>();
List<PrintingService> services = new ArrayList<>();
List<String> devices = new ArrayList<>();
JSONArray inputs = new JSONArray();
boolean idle = args.length > 1 && args[1].equals("idle");
int total = idle ? 40 : 10000;
long started = System.nanoTime();
int exit = 1;
try
{
for(int tenant = 0; tenant < 2; tenant++)
{
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "tenant-" + tenant, 0, "fixture-only-management-token-123456", "operator", password));
AtomicReference<Javalin> app = new AtomicReference<>();
QApplicationLauncher launch = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
{
host.configure(server, false);
server.withJavalinConfigurationCustomizer(app::set);
}));
launches.add(launch);
PrintingService service = launch.getQInstance().getQBits().values().stream().map(v -> v.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService();
services.add(service);
for(int lane = 0; lane < 4; lane++)
{
JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Synthetic lane").put("deviceName", "Synthetic lane").put("agentVersion", "qualification"), "operator");
String device = enrolled.getString("deviceId");
devices.add(device);
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "synthetic").put("name", "No physical printer").put("status", "READY").put("formats", new JSONArray().put("ZPL"))));
service.enablePrinter(device, "synthetic", true);
inputs.put(new JSONObject().put("origin", "http://127.0.0.1:" + app.get().port() + "/").put("username", "operator").put("password", password).put("tenant", "tenant-" + tenant).put("deviceId", device).put("deviceToken", enrolled.getString("deviceToken")));
}
}
// Only synthetic credentials inside the already protected run directory.
Files.writeString(dir.resolve("input.json"), new JSONObject().put("lanes", inputs).put("idle", idle).put("jobsPerLane", total / 8).toString());
await(dir.resolve("ready"), Duration.ofMinutes(2));
byte[] payload = ("^XA^FO20,20^FDSYNTHETIC NOT PRINTED^FS" + " ".repeat(980) + "^XZ").getBytes(StandardCharsets.US_ASCII);
String encoded = Base64.getEncoder().encodeToString(payload);
long producing = System.nanoTime();
if(idle)
{
for(int job = 0; job < total; job++)
{
int lane = job % 8;
PrintingService service = services.get(lane / 4);
JSONObject request = new JSONObject().put("deviceId", devices.get(lane)).put("printerId", "synthetic").put("title", "Synthetic idle workload").put("format", "ZPL").put("contentBase64", encoded).put("idempotencyKey", "idle-" + job);
service.submit(request);
await(dir.resolve("completed-" + (job + 1)), Duration.ofSeconds(30));
Thread.sleep(200);
}
}
else
{
try(var executor = Executors.newFixedThreadPool(8))
{
List<Future<?>> futures = new ArrayList<>();
for(int lane = 0; lane < 8; lane++)
{
final int index = lane;
futures.add(executor.submit(() ->
{
try
{
PrintingService service = services.get(index / 4);
for(int job = 0; job < 1250; job++)
{
JSONObject request = new JSONObject().put("deviceId", devices.get(index)).put("printerId", "synthetic").put("title", "Synthetic workload " + job).put("format", "ZPL").put("contentBase64", encoded).put("idempotencyKey", "lane-" + index + "-" + job);
JSONObject submitted = service.submit(request);
if(job % 25 == 0 && !submitted.getString("jobId").equals(service.submit(request).getString("jobId")))
{
throw new IllegalStateException("Replay duplicated job");
}
}
}
catch(Exception e)
{
throw new RuntimeException(e);
}
}));
}
for(var future : futures)
{
future.get(15, TimeUnit.MINUTES);
}
}
}
double producerSeconds = (System.nanoTime() - producing) / 1e9;
Files.writeString(dir.resolve("producer-done"), "done");
await(dir.resolve("done"), Duration.ofMinutes(15));
JSONObject client = new JSONObject(Files.readString(dir.resolve("result.json")));
if(!client.getBoolean("success"))
{
throw new IllegalStateException("Client reported failure");
}
JSONObject report = new JSONObject().put("success", true).put("jobs", total).put("receipts", total * 3).put("idle", idle).put("producerSeconds", producerSeconds).put("elapsedSeconds", (System.nanoTime() - started) / 1e9).put("realPrinterCalls", 0);
try(Connection connection = DriverManager.getConnection(jdbc, "sa", ""); Statement query = connection.createStatement())
{
require(query, "SELECT COUNT(*) FROM printing_job", total);
require(query, "SELECT COUNT(*) FROM printing_job WHERE status='SPOOLED'", total);
require(query, "SELECT COUNT(*) FROM printing_event", total * 3);
require(query, "SELECT COUNT(*) FROM printing_document_orphan", 0);
for(int tenant = 0; tenant < 2; tenant++)
{
require(query, "SELECT COUNT(*) FROM printing_job WHERE tenant_id='tenant-" + tenant + "'", total / 2);
require(query, "SELECT retained_bytes FROM printing_resource_usage WHERE tenant_id='tenant-" + tenant + "' AND device_id=''", (total / 2L) * payload.length);
}
JSONArray cross = new JSONArray();
for(int tenant = 0; tenant < 2; tenant++)
{
String foreign = services.get(1 - tenant).jobsPage(1, null).getJSONArray("jobs").getJSONObject(0).getString("jobId");
try
{
services.get(tenant).getJob(foreign);
throw new IllegalStateException("Foreign job visible");
}
catch(PrintingException denied)
{
if(denied.getStatus() != 404)
{
throw denied;
}
}
cross.put(true);
}
report.put("tenantIsolation", cross).put("retainedAccountingVerified", true).put("bytesPerDocument", payload.length);
}
Files.writeString(dir.resolve("server-result.json"), report.toString(2));
exit = 0;
}
catch(Throwable failure)
{
Files.writeString(dir.resolve("server-error.json"), new JSONObject().put("error", failure.getClass().getName()).put("cause", failure.getCause() == null ? "" : failure.getCause().getClass().getName()).toString());
}
finally
{
for(int i = launches.size() - 1; i >= 0; i--)
{
launches.get(i).stop();
}
try(Connection connection = DriverManager.getConnection(jdbc, "sa", ""); Statement query = connection.createStatement())
{
query.execute("SHUTDOWN");
}
Files.writeString(dir.resolve("stopped.json"), new JSONObject().put("normalStop", true).put("exitCode", exit).toString());
}
System.exit(exit);
}
/***************************************************************************
** Wait for a private coordination marker with a monotonic deadline.
***************************************************************************/
static void await(Path file, Duration limit) throws Exception
{
long deadline = System.nanoTime() + limit.toNanos();
while(!Files.exists(file))
{
if(System.nanoTime() > deadline)
{
throw new TimeoutException("Fixture deadline");
}
Thread.sleep(100);
}
}
/***************************************************************************
** Verify a synthetic database aggregate without emitting row values.
***************************************************************************/
static void require(Statement query, String sql, long expected) throws Exception
{
try(ResultSet rows = query.executeQuery(sql))
{
if(!rows.next() || rows.getLong(1) != expected)
{
throw new IllegalStateException("Synthetic count/accounting mismatch");
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Kingsrook, LLC
SPDX-License-Identifier: Apache-2.0
-->
<Configuration status="ERROR">
<Appenders><Console name="Console" target="SYSTEM_ERR"><PatternLayout pattern="%level %logger{1}: %message%n"/></Console></Appenders>
<Loggers>
<Logger name="software.amazon.awssdk" level="OFF"/>
<Logger name="org.apache.http" level="OFF"/>
<Root level="ERROR"><AppenderRef ref="Console"/></Root>
</Loggers>
</Configuration>
+20
View File
@@ -0,0 +1,20 @@
{
admin off
skip_install_trust
auto_https disable_redirects
}
https://auth.printing.internal {
tls internal
reverse_proxy 127.0.0.1:9000
}
https://qqq.printing.internal {
tls internal
reverse_proxy native-qqq:19082
}
https://callback.printing.internal {
tls internal
reverse_proxy native-client:8088
}
+24
View File
@@ -0,0 +1,24 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b AS build
# Same exact published production Core source as the normal development workload.
ADD --checksum=sha256:82fcdbf7240760939a63b219aeb15a2333b3a8a02b52307a8a328500fca71fb6 https://github.com/QRun-IO/qqq-print-agent/archive/346fc285198908c47a2ee9db7b9adb53d5e88c3c.tar.gz /tmp/client.tar.gz
RUN mkdir /client && tar -xzf /tmp/client.tar.gz -C /client --strip-components=1 && rm /tmp/client.tar.gz
RUN dotnet build /client/src/Qqq.PrintAgent.Core/Qqq.PrintAgent.Core.csproj -c Release -p:Platform=x64 -p:Version=0.3.1-rc.1 -p:RestoreLockedMode=true --nologo
RUN mkdir /core && cp /client/src/Qqq.PrintAgent.Core/bin/x64/Release/net10.0/Qqq.PrintAgent.Core.dll /core/
COPY dev/Directory.Build.props dev/Directory.Packages.props dev/.editorconfig dev/global.json /fixture/dev/
WORKDIR /fixture/dev/native-auth/client
COPY dev/native-auth/client .
COPY dev/client/WorkloadDiscovery.cs /fixture/dev/client/WorkloadDiscovery.cs
RUN dotnet build -c Release -p:RestoreLockedMode=true --nologo -o /out
RUN dotnet format --verify-no-changes --severity warn --no-restore
RUN dotnet /out/NativeAuth.dll --check-discovery
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:de4fe90a98845d75910b9769662465c84de1a934665c431279d1382b2bddc02b
WORKDIR /app
COPY --from=build /out .
COPY dev/native-auth/start-client.sh /app/start-client.sh
RUN chmod 0555 /app/start-client.sh
ENV DOTNET_EnableDiagnostics=0
ENTRYPOINT ["/app/start-client.sh"]
+30
View File
@@ -0,0 +1,30 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
FROM maven:3.9.11-eclipse-temurin-21@sha256:6fdc855a6ed81d288ca7ca37ac6ff5e9308b612485c0801d70b25a858c83d237 AS build
# DEVELOPMENT ONLY: exact public candidate, never installed into a host Maven cache.
ADD --checksum=sha256:d99bafdf5520d0dc039b598787c003f23505cec3a652eb8ded496a24c6ed6060 https://github.com/QRun-IO/qqq/archive/ecf0f550a0357cec6387c2b54efca128a405a629.tar.gz /tmp/core.tar.gz
RUN mkdir /candidate && tar -xzf /tmp/core.tar.gz -C /candidate --strip-components=1 && rm /tmp/core.tar.gz
WORKDIR /candidate
RUN mvn -B -Dmaven.repo.local=/candidate-maven -Drevision=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true -pl qqq-bom,qqq-backend-core,qqq-backend-module-rdbms,qqq-backend-module-filesystem,qqq-middleware-javalin -am install
WORKDIR /printing
COPY pom.xml mvnw ./
COPY .mvn .mvn
COPY checkstyle checkstyle
COPY src src
COPY dev/java dev/java
COPY dev/native-auth/java dev/native-auth/java
COPY LICENSE NOTICE ./
RUN chmod +x mvnw && ./mvnw -B -Dmaven.repo.local=/candidate-maven -Dqqq.version=4.1.0-native-dev-ecf0f550 -Dmaven.test.skip=true package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime
RUN mkdir /fixture-classes && javac -proc:none -cp 'target/classes:target/runtime/*' -d /fixture-classes dev/native-auth/java/*.java
FROM eclipse-temurin:21-jdk-jammy@sha256:742daacb9316be4b6fc60258f6e4ac2a11882f6c597373c92290a57bfad0d2e0
WORKDIR /app
COPY --from=build /printing/target/classes classes
COPY --from=build /printing/target/runtime lib
COPY --from=build /fixture-classes fixture
COPY dev/log4j2.xml /app/log4j2.xml
COPY dev/native-auth/start-server.sh /app/start-server.sh
RUN chmod 0555 /app/start-server.sh
ENV NATIVE_CORE_SOURCE=ecf0f550a0357cec6387c2b54efca128a405a629
ENTRYPOINT ["/app/start-server.sh"]
+99
View File
@@ -0,0 +1,99 @@
# Experimental native Authentik qualification
This optional profile preserves a **historical upstream candidate** for reproducible
qualification. The normal application now uses published QQQ RC.21 with native
session login; this profile still builds its explicit older source pin. It does
not replace the default TABLE/MinIO development environment,
install a connector, or qualify the Windows browser callback UI.
From the repository root, with Docker and PowerShell 7.4 or later:
```powershell
pwsh ./dev/Invoke-Dev.ps1 -Action Test -ExperimentalNativeAuth
```
The command creates a fresh owned project, builds the pinned sources, runs the
test, and removes its containers, network and synthetic database volumes in
`finally`. Protected input and sanitized result files remain outside the
checkout. Do not print or commit the private input or Compose environment.
An explicit fresh `-StateDirectory` preserves the same cleanup behavior.
Persistent `Start` and conversion of an existing standard environment are
refused for this profile.
The test uses actual Authentik login stages with generated synthetic accounts,
the standard QQQ OAuth2 code/session/refresh handlers, and the published .NET
Core PKCE/session client. The provider issues the authorization codes. The
client receives a real HTTPS callback and stores only the opaque QQQ session
in process; provider refresh tokens remain on the QQQ server.
The required checks are:
- Ordinary TLS rejects the fixture before its private CA is installed.
- Actual API discovery advertises the typed native session grant.
- Authorized real provider login resolves a server-derived native identity.
- A wrong-Origin cookie request and an authenticated subject without the
printing permission are rejected.
- A logged-out QQQ session cannot be replayed.
- After the 20-second provider access grant expires, standard QQQ renewal
obtains a new provider grant and preserves the principal and tenant.
- Revoking the synthetic subject's provider refresh grants prevents renewal
after the next access-grant expiry.
Success is recorded only when every check passes. A failure report contains
the stage and exception type, without callback URLs, credentials or response
bodies. This short renewal test does not prove months of unattended operation.
Printing discovery accepts stable semantic versions in major 2, including
2.0.0 and 2.1.0, through the same production Core validation and shared major
gate as the default workload. The experimental upstream API must still be
exactly 1.9.0 with `nativeSessionLogin`; typed metadata, PKCE, TLS, callback and
permission checks are unchanged. The native-client image build runs 12 fake-HTTP
compatibility checks before any provider or fixture input is used.
## Isolation and source pins
The upstream source is
[`ecf0f550a0357cec6387c2b54efca128a405a629`](https://github.com/QRun-IO/qqq/commit/ecf0f550a0357cec6387c2b54efca128a405a629).
Its archive checksum is pinned in `Dockerfile.server`. Maven installs the
development build only into `/candidate-maven` inside the image; no host Maven
cache or installed runtime is mounted or changed. Printing source is compiled
against that candidate solely for this test.
The production .NET Core client is pinned to published source
[`346fc285198908c47a2ee9db7b9adb53d5e88c3c`](https://github.com/QRun-IO/qqq-print-agent/commit/346fc285198908c47a2ee9db7b9adb53d5e88c3c)
with a checked archive hash. Authentik, PostgreSQL, Caddy, Java and .NET images
are pinned by digest in the Compose files and Dockerfiles.
Caddy issues the three fixture HTTPS hostnames using its private local CA.
`skip_install_trust` prevents automatic trust installation. Only the public
root certificate is copied to the Java and .NET containers' trust stores;
ordinary chain and hostname validation remain enabled. CA private keys stay
in the TLS container's owned volume. No Windows or browser trust store changes
occur. See [Caddy's local HTTPS documentation](https://caddyserver.com/docs/automatic-https#local-https).
The HTTPS callback receiver is internal to the Compose network. It is separate
from the installed connector's private URI callback. No callback host port is
published. The profile mounts public Caddy configuration and the two specific
private input/result files, rather than a host home or credential directory.
The QQQ fixture uses fresh standard session, redirect-state and encrypted
refresh-token tables. Its customizer maps the verified provider's synthetic
`printing_admin` group to the existing native permission. It creates no device
pairing, job, print operation or fabricated user session. RC.20 lacked the
native-session grant; RC.21 now supplies it in the ordinary application. This
fixture remains opt-in and pinned to its recorded source rather than silently
substituting a different runtime into historical results.
## Recorded local qualification
On 2026-11-01 the full disposable profile passed all eight checks and exited zero.
Independent inventory confirmed zero owned containers, volumes and networks after
cleanup. This is evidence for the pinned experimental tuple above, not adoption
into the default released runtime or installed Windows UI acceptance.
A fresh run against server source `3592dec` (printing API 2.1.0) and the reviewed
probe compatibility correction also passed all eight checks on 2026-11-01.
All source files matched the pre-run hash manifest through completion, and
independent cleanup again found no owned containers, volumes or networks.
The native image's 12 discovery checks and default workload's nine checks pass.
The historical source pin and installed-browser limitations above still apply.
@@ -0,0 +1,75 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Threading.Channels;
namespace Printing.NativeAuth;
/// <summary>Receives the actual HTTPS-proxied callback only inside this test network.</summary>
internal sealed class CallbackReceiver : IDisposable
{
private readonly HttpListener _listener = new();
private readonly Channel<Uri> _callbacks = Channel.CreateBounded<Uri>(1);
private readonly Uri _callback;
/// <summary>Receive callbacks only inside the owned native-auth container.</summary>
internal CallbackReceiver(Uri callback)
{
_callback = callback;
_listener.Prefixes.Add("http://+:8088/");
_listener.Start();
_ = ReceiveAsync();
}
/// <summary>Accept only the configured callback path and publish one bounded result.</summary>
private async Task ReceiveAsync()
{
try
{
while(_listener.IsListening)
{
HttpListenerContext context = await _listener.GetContextAsync();
Uri? request = context.Request.Url;
bool valid = request is not null
&& string.Equals(request.AbsolutePath, _callback.AbsolutePath, StringComparison.Ordinal)
&& string.Equals(context.Request.Headers["Host"], _callback.Authority, StringComparison.Ordinal)
&& string.Equals(context.Request.Headers["X-Forwarded-Proto"], "https", StringComparison.Ordinal)
&& string.Equals(context.Request.HttpMethod, "GET", StringComparison.Ordinal)
&& request.Query.Length is > 0 and < 16384;
if(valid)
{
var actual = new UriBuilder(_callback) { Query = request!.Query.TrimStart('?') };
valid = _callbacks.Writer.TryWrite(actual.Uri);
}
context.Response.StatusCode = valid ? 200 : 400;
context.Response.ContentLength64 = 0;
context.Response.Close();
}
}
catch(Exception exception) when(exception is HttpListenerException or ObjectDisposedException)
{
_callbacks.Writer.TryComplete();
}
}
/// <summary>Await the actual provider callback under the caller deadline.</summary>
internal ValueTask<Uri> ReadAsync(CancellationToken token) => _callbacks.Reader.ReadAsync(token);
/// <summary>Close the owned callback listener.</summary>
public void Dispose() => _listener.Close();
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
namespace Printing.NativeAuth;
/// <summary>Small bounded fixture transport; no redirects, proxy or response-body diagnostics.</summary>
internal sealed class FixtureHttp : IDisposable
{
/// <summary>Carry only the parsed bounded response and optional redirect location.</summary>
internal sealed record Reply(HttpStatusCode Status, JsonObject? Json, Uri? Location);
/// <summary>Record fixed classifications without credentials, URLs, or response bodies.</summary>
internal sealed record TraceEntry(string Kind, int Status, bool HasQuery, string? LocationKind, bool LocationHasQuery);
internal static List<TraceEntry> Trace { get; } = [];
internal static string? LastRequestKind
{
get;
private set;
}
internal static int? LastStatus
{
get;
private set;
}
private readonly HttpClient _client;
private readonly CookieContainer _cookies = new();
private readonly Uri _origin;
private readonly string? _adminToken;
/// <summary>Configure bounded same-origin HTTP with redirects and proxies disabled.</summary>
internal FixtureHttp(Uri origin, bool cookies = false, string? adminToken = null)
{
_origin = origin;
_adminToken = adminToken;
_client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = cookies, CookieContainer = _cookies })
{
Timeout = TimeSpan.FromSeconds(15),
MaxResponseContentBufferSize = 524288,
};
}
/// <summary>Send one origin-confined request and retain bounded sanitized diagnostics.</summary>
internal async Task<Reply> SendAsync(string method, Uri uri, JsonObject? body, CancellationToken cancellationToken)
{
if(!string.Equals(uri.Scheme, "https", StringComparison.Ordinal) || !string.Equals(uri.GetLeftPart(UriPartial.Authority), _origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
throw new InvalidOperationException("A fixture request left its configured HTTPS origin.");
}
using var request = new HttpRequestMessage(new HttpMethod(method), uri);
// Fixed classifications only: never retain a query, callback, identifier or body.
LastRequestKind = Classify(uri, method);
LastStatus = null;
if(_adminToken is not null)
{
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal))
{
throw new InvalidOperationException("Administrative credential scope refused.");
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
}
if(body is not null)
{
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
request.Headers.Add("Origin", _origin.GetLeftPart(UriPartial.Authority));
request.Headers.Referrer = _origin;
Cookie? csrf = _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => string.Equals(cookie.Name, "authentik_csrf", StringComparison.Ordinal));
csrf ??= _cookies.GetCookies(_origin).Cast<Cookie>().SingleOrDefault(cookie => string.Equals(cookie.Name, "csrftoken", StringComparison.Ordinal));
if(csrf is not null)
{
request.Headers.Add("X-authentik-CSRF", csrf.Value);
}
}
using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken);
LastStatus = (int)response.StatusCode;
Uri? location = response.Headers.Location is null ? null : new Uri(_origin, response.Headers.Location);
if(Trace.Count == 32)
{
Trace.RemoveAt(0);
}
Trace.Add(new TraceEntry(LastRequestKind, (int)response.StatusCode, uri.Query.Length != 0,
location is null ? null : Classify(location, "GET"), location?.Query.Length > 0));
string content = await response.Content.ReadAsStringAsync(cancellationToken);
JsonObject? json = string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal) && content.Length != 0
? JsonNode.Parse(content)?.AsObject() : null;
return new Reply(response.StatusCode, json, response.Headers.Location);
}
/// <summary>Send one origin-confined request and retain bounded sanitized diagnostics.</summary>
internal Task<Reply> SendAsync(string method, string path, JsonObject? body, CancellationToken cancellationToken)
=> SendAsync(method, new Uri(_origin, path), body, cancellationToken);
/// <summary>Map an endpoint to a fixed label without exposing its path or query.</summary>
private static string Classify(Uri uri, string method) => string.Equals(uri.Host, "callback.printing.internal", StringComparison.Ordinal) ? "callback"
: uri.AbsolutePath.StartsWith("/application/o/authorize", StringComparison.Ordinal) ? "provider-authorization"
: uri.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal) ? "provider-flow-page"
: uri.AbsolutePath.StartsWith("/api/v3/flows/executor/", StringComparison.Ordinal) ? "provider-flow-executor-" + method
: "fixture-api";
/// <summary>Require the expected status and a parsed JSON object.</summary>
internal static JsonObject RequireJson(Reply reply, HttpStatusCode expected)
{
if(reply.Status != expected || reply.Json is null)
{
throw new InvalidOperationException("Unexpected fixture API status or document shape.");
}
return reply.Json;
}
/// <summary>Dispose the owned HTTP client and handler.</summary>
public void Dispose() => _client.Dispose();
}
+16
View File
@@ -0,0 +1,16 @@
<!--
Copyright 2026 Kingsrook, LLC
SPDX-License-Identifier: Apache-2.0
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Reference Include="Qqq.PrintAgent.Core">
<HintPath>$(CoreAssemblyPath)</HintPath>
</Reference>
<Compile Include="../../client/WorkloadDiscovery.cs" Link="WorkloadDiscovery.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
using Printing.Development;
using Qqq.PrintAgent.Core.Authentication;
namespace Printing.NativeAuth;
/// <summary>Requires the pinned experimental native grant before provider authentication.</summary>
internal static class NativeAuthDiscovery
{
/// <summary>Checks actual production discovery without sending credentials.</summary>
internal static async Task DiscoverAsync(QqqSessionClient client, CancellationToken token)
{
QqqApiDiscovery discovery = await WorkloadDiscovery.DiscoverRealtimeAsync(client, token);
if(!string.Equals(discovery.ApiVersion, "1.9.0", StringComparison.Ordinal) || !discovery.Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
{
throw new InvalidOperationException("The actual server did not advertise the pinned native-session API.");
}
}
}
@@ -0,0 +1,110 @@
/*
* Copyright 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
using System.Net;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
namespace Printing.NativeAuth;
/// <summary>Exercises the real native probe gate against bounded, credential-free HTTP responses.</summary>
internal static class NativeAuthDiscoveryChecks
{
/// <summary>Accepts compatible printing releases while preserving the exact upstream grant requirement.</summary>
private static readonly string[] s_channels = ["api"];
/// <summary>Check the native grant and compatible extension using fake discovery responses.</summary>
internal static async Task RunAsync()
{
(string PrintingVersion, string ApiVersion, bool NativeGrant, bool Accepted)[] cases =
[
("2.0.0", "1.9.0", true, true),
("2.1.0", "1.9.0", true, true),
("2.9.17", "1.9.0", true, true),
("1.9.0", "1.9.0", true, false),
("3.0.0", "1.9.0", true, false),
("2.1", "1.9.0", true, false),
("2.01.0", "1.9.0", true, false),
("2.1.0-rc.1", "1.9.0", true, false),
("not-a-version", "1.9.0", true, false),
("2.1.0", "1.8.0", true, false),
("2.1.0", "1.10.0", true, false),
("2.1.0", "1.9.0", false, false),
];
foreach((string printingVersion, string apiVersion, bool nativeGrant, bool expected) in cases)
{
using var handler = new DiscoveryHandler(printingVersion, apiVersion, nativeGrant);
using var client = new QqqSessionClient(new Uri("https://printing.example.invalid"), handler);
bool accepted = false;
try
{
await NativeAuthDiscovery.DiscoverAsync(client, CancellationToken.None);
accepted = true;
}
catch(QqqSessionException error) when(error.Failure == QqqSessionException.FailureKind.ConfigurationRequired)
{
// Expected for incompatible discovery: the case stays rejected and the comparison below judges it.
}
catch(InvalidDataException)
{
// Expected for malformed discovery values: the case stays rejected and the comparison below judges it.
}
catch(InvalidOperationException)
{
// Expected when native-session discovery is absent: the case stays rejected and judged below.
}
if(accepted != expected || handler.Requests != 1)
{
throw new InvalidDataException($"Native discovery compatibility case {printingVersion}/{apiVersion}/grant={nativeGrant} failed.");
}
}
Console.WriteLine($"Native discovery compatibility checks passed: {cases.Length}.");
}
/// <summary>Supplies discovery only and fails if the probe attempts authenticated traffic.</summary>
private sealed class DiscoveryHandler(string printingVersion, string apiVersion, bool nativeGrant) : HttpMessageHandler
{
/// <summary>Ensures rejected metadata still traversed the public HTTP discovery boundary.</summary>
internal int Requests
{
get;
private set;
}
/// <summary>Refuses any credential or request outside the public discovery boundary.</summary>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if(request.Method != HttpMethod.Get
|| !string.Equals(request.RequestUri?.AbsoluteUri, "https://printing.example.invalid/qqq/v1/version", StringComparison.Ordinal)
|| request.Headers.Contains("Cookie")
|| request.Headers.Contains("Authorization")
|| request.Headers.Contains("X-QQQ-Device-Id") || request.Headers.Contains("X-QQQ-Device-Token"))
{
throw new InvalidOperationException("Native discovery must precede authentication and device proof.");
}
Requests++;
string[] features = nativeGrant
? ["version", "metaData", "processes", "processCancel", "extensions", "widgets", "nativeSessionLogin"]
: ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
string json = JsonSerializer.Serialize(new
{
apiVersion,
features,
clientChannels = s_channels,
extensions = new[] { new { name = "printing", version = printingVersion, pathPrefix = "/qbit/printing" } }
});
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) });
}
}
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Qqq.PrintAgent.Core.Authentication;
namespace Printing.NativeAuth;
/// <summary>Qualify real provider sessions only in the selected experimental environment.</summary>
internal static class Program
{
private const string CoreSource = "ecf0f550a0357cec6387c2b54efca128a405a629";
private const string ClientSource = "346fc285198908c47a2ee9db7b9adb53d5e88c3c";
private const string InputPath = "/work/input.json";
private const string ResultPath = "/work/result.json";
private const string TlsMarker = "/tmp/printing-native-untrusted-tls-passed";
private static readonly JsonSerializerOptions s_reportOptions = new() { WriteIndented = true };
private static readonly List<string> s_checks = [];
private static string s_stage = "input";
private static bool? s_authorizationRequestMatches;
/// <summary>Validate synthetic input and record bounded evidence without credential output.</summary>
public static async Task<int> Main(string[] arguments)
{
if(arguments is ["--check-discovery"])
{
await NativeAuthDiscoveryChecks.RunAsync();
return 0;
}
JsonObject? input = null;
try
{
if(arguments.Length != 1 || arguments[0] is not ("assert-untrusted" or "test"))
{
throw new InvalidOperationException("An explicit fixture action is required.");
}
if(new FileInfo(InputPath).Length is <= 0 or > 65536)
{
throw new InvalidOperationException("Fixture input size is invalid.");
}
input = JsonNode.Parse(await File.ReadAllTextAsync(InputPath))!.AsObject();
ValidateInput(input);
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5));
if(string.Equals(arguments[0], "assert-untrusted", StringComparison.Ordinal))
{
s_stage = "untrusted-tls";
await AssertUntrustedAsync(new Uri(ProviderSetup.Text(input, "providerOrigin")), deadline.Token);
await File.WriteAllTextAsync(TlsMarker, "passed", deadline.Token);
return 0;
}
if(!File.Exists(TlsMarker))
{
throw new InvalidOperationException("The ordinary TLS negative control did not run.");
}
s_checks.Add("untrusted-tls-refused");
await QualifyAsync(input, deadline.Token);
await WriteResultAsync(input, "PASS", null, null);
Console.WriteLine("Experimental native Authentik qualification passed; development-only unpublished core.");
return 0;
}
catch(Exception exception)
{
// Exception messages, response bodies, callback URLs and credentials are deliberately withheld.
await WriteResultAsync(input, "FAILED", exception.GetType().Name,
exception is QqqSessionException sessionException ? sessionException.Failure.ToString() : null);
Console.Error.WriteLine("Experimental native Authentik qualification failed at " + s_stage + " (" + exception.GetType().Name + ").");
return 1;
}
}
/// <summary>Refuse fixture identities, origins, or source tuples outside the pinned contract.</summary>
private static void ValidateInput(JsonObject input)
{
if(!Regex.IsMatch(ProviderSetup.Text(input, "project"), "^qbit-printing-dev-[a-f0-9]{32}$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking)
|| !string.Equals(ProviderSetup.Text(input, "coreSource"), CoreSource, StringComparison.Ordinal)
|| !string.Equals(ProviderSetup.Text(input, "clientSource"), ClientSource, StringComparison.Ordinal)
|| !string.Equals(ProviderSetup.Text(input, "providerOrigin"), "https://auth.printing.internal", StringComparison.Ordinal)
|| !string.Equals(ProviderSetup.Text(input, "qqqOrigin"), "https://qqq.printing.internal", StringComparison.Ordinal)
|| !string.Equals(ProviderSetup.Text(input, "callback"), "https://callback.printing.internal/oauth/callback", StringComparison.Ordinal)
|| !string.Equals(ProviderSetup.Text(input, "issuer"), "https://auth.printing.internal/application/o/" + ProviderSetup.Text(input, "project") + "/", StringComparison.Ordinal)
|| !string.Equals(ProviderSetup.Text(input, "clientId"), ProviderSetup.Text(input, "project"), StringComparison.Ordinal))
{
throw new InvalidOperationException("Fixture identity or destination differs from the pinned local contract.");
}
}
/// <summary>Create the production session client with redirects, cookies, and proxies controlled.</summary>
private static QqqSessionClient Session(Uri origin) => new(origin,
new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false });
/// <summary>Require TLS to reject the private CA before scoped trust is installed.</summary>
private static async Task AssertUntrustedAsync(Uri origin, CancellationToken token)
{
using var transport = new FixtureHttp(origin);
try
{
await transport.SendAsync("GET", "/-/health/live/", null, token);
}
catch(HttpRequestException exception)
{
for(Exception? cause = exception; cause is not null; cause = cause.InnerException)
{
if(cause is AuthenticationException)
{
return;
}
}
throw new InvalidOperationException("The negative TLS control failed for a reason other than certificate authentication.", exception);
}
throw new InvalidOperationException("The test unexpectedly trusted its private CA before scoped installation.");
}
/// <summary>Exercise real discovery, authorization, origin enforcement, logout, renewal, and revocation.</summary>
private static async Task QualifyAsync(JsonObject input, CancellationToken token)
{
var origin = new Uri(ProviderSetup.Text(input, "qqqOrigin"));
var providerOrigin = new Uri(ProviderSetup.Text(input, "providerOrigin"));
var callback = new Uri(ProviderSetup.Text(input, "callback"));
s_stage = "readiness";
await WaitForQqqAsync(origin, token);
using var provider = new ProviderSetup(input);
s_stage = "provider-readiness";
await provider.WaitReadyAsync(token);
s_stage = "provider-configuration";
await provider.ConfigureAsync(token);
using var receiver = new CallbackReceiver(callback);
using QqqSessionClient first = Session(origin);
s_stage = "typed-native-discovery";
await NativeAuthDiscovery.DiscoverAsync(first, token);
QqqAuthenticationMetadata metadata = await first.GetAuthenticationMetadataAsync(token);
if(!string.Equals(metadata.Values?.ClientId, ProviderSetup.Text(input, "clientId"), StringComparison.Ordinal))
{
throw new InvalidOperationException("Native metadata did not retain the configured client identifier.");
}
s_checks.Add("typed-native-grant");
s_stage = "real-provider-login";
await LoginAsync(first, receiver, input, providerOrigin, callback, authorized: true, token);
s_stage = "authorized-native-identity";
QqqSessionBinding identity = await first.GetSessionIdentityAsync(token);
if(string.IsNullOrWhiteSpace(identity.PrincipalId) || !string.Equals(identity.TenantId, "printing-native-dev", StringComparison.Ordinal) || identity.DeviceId is not null)
{
throw new InvalidOperationException("The native subject did not receive the intended server-derived identity.");
}
s_checks.Add("real-provider-login");
s_stage = "wrong-origin-cookie";
await AssertWrongOriginAsync(origin, first.ExportCredential(), token);
await first.GetSessionIdentityAsync(token);
s_checks.Add("wrong-origin-cookie-denied");
s_stage = "permission-denied-user";
using(QqqSessionClient denied = Session(origin))
{
await LoginAsync(denied, receiver, input, providerOrigin, callback, authorized: false, token);
await ExpectFailureAsync(() => denied.GetSessionIdentityAsync(token), QqqSessionException.FailureKind.PermissionDenied);
await denied.SignOutAsync(token);
}
s_checks.Add("permission-denied-user");
await AssertLogoutReplayAsync(first, origin, token);
await QualifyRenewalAsync(input, provider, receiver, origin, providerOrigin, callback, token);
}
/// <summary>Verify that signing out invalidates the exact previously exported native cookie.</summary>
private static async Task AssertLogoutReplayAsync(QqqSessionClient first, Uri origin, CancellationToken token)
{
s_stage = "logout-replay";
QqqSessionCredential retired = first.ExportCredential();
await first.SignOutAsync(token);
using(QqqSessionClient replay = Session(origin))
{
await ExpectFailureAsync(() => replay.RestoreSessionAsync(retired, token), QqqSessionException.FailureKind.AuthenticationRequired);
}
s_checks.Add("logout-replay-denied");
}
/// <summary>Cross real provider expiry and revocation while preserving the authenticated subject.</summary>
private static async Task QualifyRenewalAsync(JsonObject input, ProviderSetup provider, CallbackReceiver receiver,
Uri origin, Uri providerOrigin, Uri callback, CancellationToken token)
{
s_stage = "fresh-login-for-renewal";
using QqqSessionClient renewable = Session(origin);
await LoginAsync(renewable, receiver, input, providerOrigin, callback, authorized: true, token);
QqqSessionBinding before = await renewable.GetSessionIdentityAsync(token);
HashSet<string> previousGrants = await provider.AccessGrantIdsAsync(token);
s_stage = "server-refresh-after-expiry";
await Task.Delay(TimeSpan.FromSeconds(22), token);
await renewable.ResumeSessionAsync(token);
QqqSessionBinding after = await renewable.GetSessionIdentityAsync(token);
HashSet<string> refreshedGrants = await provider.AccessGrantIdsAsync(token);
if(before != after || !refreshedGrants.Except(previousGrants, StringComparer.Ordinal).Any())
{
throw new InvalidOperationException("Renewal did not preserve the identity and obtain a new actual provider grant.");
}
s_checks.Add("server-refresh-after-expiry");
s_stage = "provider-revocation-after-expiry";
await provider.RevokeRefreshGrantsAsync(token);
await Task.Delay(TimeSpan.FromSeconds(22), token);
await ExpectFailureAsync(() => renewable.ResumeSessionAsync(token), QqqSessionException.FailureKind.AuthenticationRequired);
s_checks.Add("provider-revocation-denied");
s_stage = "complete";
}
/// <summary>Use the real PKCE attempt and provider callback without synthesizing identity.</summary>
private static async Task LoginAsync(QqqSessionClient client, CallbackReceiver receiver, JsonObject input,
Uri providerOrigin, Uri callback, bool authorized, CancellationToken token)
{
string role = authorized ? "authorized" : "denied";
s_stage = role + "-begin-pkce";
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(callback, token);
s_stage = role + "-generated-authorization-request";
var parameters = attempt.AuthorizationUri.Query.TrimStart('?').Split('&')
.Select(entry => entry.Split('=', 2)).ToDictionary(pair => Uri.UnescapeDataString(pair[0]), pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
s_authorizationRequestMatches = string.Equals(attempt.AuthorizationUri.AbsolutePath, "/application/o/authorize/", StringComparison.Ordinal)
&& string.Equals(parameters.GetValueOrDefault("client_id"), ProviderSetup.Text(input, "clientId"), StringComparison.Ordinal)
&& string.Equals(parameters.GetValueOrDefault("redirect_uri"), callback.AbsoluteUri, StringComparison.Ordinal)
&& string.Equals(parameters.GetValueOrDefault("response_type"), "code", StringComparison.Ordinal)
&& string.Equals(parameters.GetValueOrDefault("code_challenge_method"), "S256", StringComparison.Ordinal)
&& parameters.GetValueOrDefault("state")?.Length == 43;
if(s_authorizationRequestMatches != true)
{
throw new InvalidOperationException("The generated authorization request differs from the exact provider registration.");
}
s_stage = role + "-provider-flow";
await ProviderFlow.AuthenticateAsync(attempt.AuthorizationUri, providerOrigin, callback,
ProviderSetup.Text(input, role + "Username"), ProviderSetup.Text(input, role + "Password"), token);
s_stage = role + "-callback";
Uri actualCallback = await receiver.ReadAsync(token);
s_stage = role + "-exchange";
await client.CompleteBrowserLoginAsync(attempt, actualCallback, token);
}
/// <summary>Require the precise native authentication or permission failure.</summary>
private static async Task ExpectFailureAsync(Func<Task> operation, QqqSessionException.FailureKind expected)
{
try
{
await operation();
}
catch(QqqSessionException exception) when(exception.Failure == expected) { return; }
throw new InvalidOperationException("The expected native authentication or permission denial did not occur.");
}
/// <summary>Verify the authenticated native boundary rejects a foreign Origin.</summary>
private static async Task AssertWrongOriginAsync(Uri origin, QqqSessionCredential credential, CancellationToken token)
{
using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false, UseProxy = false })
{
Timeout = TimeSpan.FromSeconds(15),
MaxResponseContentBufferSize = 65536
};
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(origin, "/qqq/v1/qbit/printing/session"));
request.Headers.Add("X-QQQ-Client", QqqSessionClient.ClientChannel);
request.Headers.Add("Cookie", "sessionUUID=" + credential.SessionToken);
request.Headers.Add("Origin", "https://wrong-origin.invalid");
using HttpResponseMessage response = await http.SendAsync(request, token);
if(response.StatusCode != HttpStatusCode.Forbidden)
{
throw new InvalidOperationException("The fixture did not reject a wrong-Origin cookie request.");
}
}
/// <summary>Bound startup readiness reads to the owned QQQ host.</summary>
private static async Task WaitForQqqAsync(Uri origin, CancellationToken token)
{
using var http = new FixtureHttp(origin);
for(int attempt = 0; attempt < 45; attempt++)
{
try
{
FixtureHttp.Reply response = await http.SendAsync("GET", "/qqq/v1/version", null, token);
if(response.Status == HttpStatusCode.OK && response.Json is not null)
{
return;
}
}
catch(HttpRequestException)
{
// The owned host is still starting; the bounded attempt loop retries and then fails explicitly.
}
await Task.Delay(TimeSpan.FromSeconds(2), token);
}
throw new InvalidOperationException("The owned native host did not become ready.");
}
/// <summary>Write only allowlisted classifications and results to the private report.</summary>
private static Task WriteResultAsync(JsonObject? input, string status, string? failureType, string? sessionFailure)
=> File.WriteAllTextAsync(ResultPath, JsonSerializer.Serialize(new
{
status,
scope = "DEVELOPMENT_ONLY_UNRELEASED",
coreSource = CoreSource,
clientSource = ClientSource,
project = input?["project"]?.ToString(),
observedUtc = DateTimeOffset.UtcNow,
stage = s_stage,
failureType,
sessionFailure,
lastRequestKind = FixtureHttp.LastRequestKind,
lastHttpStatus = FixtureHttp.LastStatus,
lastProviderComponent = ProviderFlow.LastComponent,
authorizationRequestMatches = s_authorizationRequestMatches,
requestClassifications = FixtureHttp.Trace,
providerLookupKind = ProviderSetup.LastLookupKind,
providerLookupCount = ProviderSetup.LastLookupCount,
providerReadinessCounts = ProviderSetup.ReadinessCounts,
checks = s_checks,
installedUiAcceptance = false,
providerRefreshTokensInClient = false,
}, s_reportOptions));
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Text.Json.Nodes;
namespace Printing.NativeAuth;
/// <summary>Uses Authentik's real synthetic-user login stages; never fabricates a session or code.</summary>
internal static class ProviderFlow
{
internal static string? LastComponent
{
get;
private set;
}
/// <summary>Follow bounded provider navigation while confining redirects to fixture origins.</summary>
internal static async Task AuthenticateAsync(Uri authorization, Uri origin, Uri callback, string username, string password, CancellationToken token)
{
using var browser = new FixtureHttp(origin, cookies: true);
using var callbackHttp = new FixtureHttp(new Uri(callback.GetLeftPart(UriPartial.Authority)));
Uri next = authorization;
for(int hop = 0; hop < 24; hop++)
{
if(string.Equals(next.GetLeftPart(UriPartial.Authority), callback.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
if(!string.Equals(next.AbsolutePath, callback.AbsolutePath, StringComparison.Ordinal))
{
throw new InvalidOperationException("Unexpected callback path.");
}
FixtureHttp.Reply result = await callbackHttp.SendAsync("GET", next, null, token);
if(result.Status != HttpStatusCode.OK)
{
throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect.");
}
return;
}
if(!string.Equals(next.GetLeftPart(UriPartial.Authority), origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
throw new InvalidOperationException("A provider redirect left the fixture.");
}
FixtureHttp.Reply page = await browser.SendAsync("GET", next, null, token);
if(page.Status is HttpStatusCode.Redirect or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect)
{
next = new Uri(next, page.Location ?? throw new InvalidOperationException("Missing provider redirect."));
continue;
}
if(page.Status != HttpStatusCode.OK || !next.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal))
{
throw new InvalidOperationException("Unexpected provider login navigation.");
}
string slug = next.AbsolutePath["/if/flow/".Length..].Trim('/');
if(slug.Length == 0 || slug.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-'))
{
throw new InvalidOperationException("Unexpected provider flow identifier.");
}
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
next = await CompleteStagesAsync(browser, executor, origin, username, password, token);
}
throw new InvalidOperationException("Provider redirect bound exceeded.");
}
/// <summary>Answer at most ten real provider challenges and return its declared redirect.</summary>
private static async Task<Uri> CompleteStagesAsync(FixtureHttp browser, Uri executor, Uri origin, string username, string password, CancellationToken token)
{
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
for(int stage = 0; stage < 10; stage++)
{
string component = ProviderSetup.Text(challenge, "component");
LastComponent = component switch
{
"ak-stage-identification" => "identification",
"ak-stage-password" => "password",
"ak-stage-consent" => "consent",
"xak-flow-redirect" => "redirect",
"ak-stage-access-denied" => "access-denied",
"ak-stage-flow-error" => "flow-error",
_ => "unsupported",
};
if(string.Equals(component, "xak-flow-redirect", StringComparison.Ordinal))
{
return new Uri(origin, ProviderSetup.Text(challenge, "to"));
}
JsonObject response = component switch
{
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
};
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
{
throw new InvalidOperationException("The real provider rejected the synthetic login response.");
}
}
throw new InvalidOperationException("Provider stage bound exceeded.");
}
/// <summary>Follow only same-executor POST/redirect/GET responses and require a JSON challenge.</summary>
private static async Task<JsonObject> ChallengeAsync(FixtureHttp browser, Uri executor, FixtureHttp.Reply response, CancellationToken token)
{
// Authentik uses POST/redirect/GET when a real stage accepts its answer.
// Follow only that same HTTPS executor; other navigation uses typed flow redirects.
for(int redirect = 0; redirect < 8; redirect++)
{
if(response.Status == HttpStatusCode.OK)
{
return FixtureHttp.RequireJson(response, HttpStatusCode.OK);
}
if(response.Status is not (HttpStatusCode.Redirect or HttpStatusCode.SeeOther) || response.Location is null)
{
throw new InvalidOperationException("Unexpected provider stage response.");
}
var location = new Uri(executor, response.Location);
if(!string.Equals(location.GetLeftPart(UriPartial.Authority), executor.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)
|| !string.Equals(location.AbsolutePath, executor.AbsolutePath, StringComparison.Ordinal))
{
throw new InvalidOperationException("A stage redirect left its exact HTTPS executor.");
}
response = await browser.SendAsync("GET", location, null, token);
}
throw new InvalidOperationException("Provider stage redirect bound exceeded.");
}
}
+288
View File
@@ -0,0 +1,288 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Text.Json.Nodes;
namespace Printing.NativeAuth;
/// <summary>Configures only the fresh project's synthetic provider and subjects.</summary>
internal sealed class ProviderSetup : IDisposable
{
internal static string? LastLookupKind
{
get;
private set;
}
internal static int? LastLookupCount
{
get;
private set;
}
internal static Dictionary<string, int> ReadinessCounts { get; } = new(StringComparer.Ordinal);
private readonly JsonObject _input;
private readonly FixtureHttp _admin;
private string _provider = "";
private string _authorizedUser = "";
/// <summary>Use the private bootstrap token only with the configured provider origin.</summary>
internal ProviderSetup(JsonObject input)
{
_input = input;
_admin = new FixtureHttp(new Uri(Text(input, "providerOrigin")), adminToken: Text(input, "adminToken"));
}
/// <summary>Read required synthetic input without disclosing its value in failures.</summary>
internal static string Text(JsonObject document, string key) => document[key]?.ToString() ?? throw new InvalidOperationException("Required fixture field is absent.");
/// <summary>Require one provider metadata object and retain only its lookup classification.</summary>
private async Task<JsonObject> ExactAsync(string path, CancellationToken token)
{
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", path, null, token), HttpStatusCode.OK);
JsonArray records = page["results"]!.AsArray();
LastLookupKind = LookupKind(path);
LastLookupCount = records.Count;
if(records.Count != 1)
{
throw new InvalidOperationException("Required provider object is absent or ambiguous.");
}
return records[0]!.AsObject();
}
/// <summary>Create one synthetic object and require its successful JSON response.</summary>
private async Task<JsonObject> CreateAsync(string path, JsonObject body, CancellationToken token)
=> FixtureHttp.RequireJson(await _admin.SendAsync("POST", path, body, token), HttpStatusCode.Created);
/// <summary>Wait for complete default flows and scopes before configuring the provider.</summary>
internal async Task WaitReadyAsync(CancellationToken token)
{
string[] paths = ["/api/v3/flows/instances/?slug=default-authentication-flow",
"/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent",
"/api/v3/flows/instances/?slug=default-provider-invalidation-flow",
"/api/v3/propertymappings/provider/scope/?scope_name=openid",
"/api/v3/propertymappings/provider/scope/?scope_name=profile",
"/api/v3/propertymappings/provider/scope/?scope_name=email",
"/api/v3/propertymappings/provider/scope/?scope_name=offline_access"];
for(int attempt = 0; attempt < 60; attempt++)
{
bool ready = true;
try
{
foreach(string path in paths)
{
FixtureHttp.Reply reply = await _admin.SendAsync("GET", path, null, token);
JsonArray? records = reply.Status == HttpStatusCode.OK ? reply.Json?["results"] as JsonArray : null;
string kind = LookupKind(path);
ReadinessCounts[kind] = records?.Count ?? -1;
if(records?.Count > 1)
{
throw new InvalidOperationException("An owned provider bootstrap dependency is ambiguous.");
}
bool present = records is { Count: 1 };
if(string.Equals(kind, "authentication-flow", StringComparison.Ordinal))
{
present &= records is { Count: 1 } && records[0]?["stages"] is JsonArray { Count: >= 4 };
}
ready &= present;
}
if(ready)
{
return;
}
}
catch(HttpRequestException)
{
// The owned provider is still starting; the bounded attempt loop retries and then fails explicitly.
}
await Task.Delay(TimeSpan.FromSeconds(2), token);
}
throw new InvalidOperationException("The owned provider bootstrap did not become ready.");
}
/// <summary>Classify prerequisite lookups using fixed diagnostic names.</summary>
private static string LookupKind(string path) => path switch
{
"/api/v3/flows/instances/?slug=default-authentication-flow" => "authentication-flow",
"/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent" => "authorization-flow",
"/api/v3/flows/instances/?slug=default-provider-invalidation-flow" => "invalidation-flow",
"/api/v3/propertymappings/provider/scope/?scope_name=openid" => "scope-openid",
"/api/v3/propertymappings/provider/scope/?scope_name=profile" => "scope-profile",
"/api/v3/propertymappings/provider/scope/?scope_name=email" => "scope-email",
"/api/v3/propertymappings/provider/scope/?scope_name=offline_access" => "scope-offline-access",
_ => "other",
};
/// <summary>Register the synthetic OAuth provider and authorized and denied users.</summary>
internal async Task ConfigureAsync(CancellationToken token)
{
JsonObject authentication = await ExactAsync("/api/v3/flows/instances/?slug=default-authentication-flow", token);
JsonObject authorization = await ExactAsync("/api/v3/flows/instances/?slug=default-provider-authorization-implicit-consent", token);
JsonObject invalidation = await ExactAsync("/api/v3/flows/instances/?slug=default-provider-invalidation-flow", token);
var mappings = new JsonArray();
foreach(string scope in new[] { "openid", "profile", "email", "offline_access" })
{
mappings.Add((await ExactAsync("/api/v3/propertymappings/provider/scope/?scope_name=" + scope, token))["pk"]!.DeepClone());
}
JsonObject groupsMapping = await CreateAsync("/api/v3/propertymappings/provider/scope/", new JsonObject
{
["name"] = Text(_input, "project") + "-groups",
["scope_name"] = "profile",
["expression"] = "return {\"groups\": [group.name for group in user.ak_groups.all()]}",
}, token);
mappings.Add(groupsMapping["pk"]!.DeepClone());
JsonObject group = await CreateAsync("/api/v3/core/groups/", new JsonObject { ["name"] = "printing_admin", ["is_superuser"] = false }, token);
JsonObject user = await CreateUserAsync("authorizedUsername", "authorizedPassword", new JsonArray(group["pk"]!.DeepClone()), token);
_authorizedUser = Text(user, "pk");
await CreateUserAsync("deniedUsername", "deniedPassword", new JsonArray(), token);
JsonObject provider = await CreateAsync("/api/v3/providers/oauth2/", new JsonObject
{
["name"] = Text(_input, "project"),
["authentication_flow"] = authentication["pk"]!.DeepClone(),
["authorization_flow"] = authorization["pk"]!.DeepClone(),
["invalidation_flow"] = invalidation["pk"]!.DeepClone(),
["client_type"] = "confidential",
["grant_types"] = new JsonArray("authorization_code", "refresh_token"),
["client_id"] = Text(_input, "clientId"),
["client_secret"] = Text(_input, "clientSecret"),
["access_code_validity"] = "minutes=1",
["access_token_validity"] = "seconds=20",
["refresh_token_validity"] = "minutes=5",
["refresh_token_threshold"] = "minutes=4",
["redirect_uris"] = new JsonArray(new JsonObject { ["matching_mode"] = "strict", ["url"] = Text(_input, "callback") }),
["property_mappings"] = mappings,
["issuer_mode"] = "per_provider",
["include_claims_in_id_token"] = true,
}, token);
_provider = Text(provider, "pk");
if(!string.Equals(Text(provider, "client_id"), Text(_input, "clientId"), StringComparison.Ordinal) || !string.Equals(Text(provider, "authentication_flow"), Text(authentication, "pk"), StringComparison.Ordinal))
{
throw new InvalidOperationException("The real provider did not retain the configured client identifier.");
}
JsonObject application = await CreateAsync("/api/v3/core/applications/", new JsonObject
{
["name"] = Text(_input, "project"),
["slug"] = Text(_input, "project"),
["provider"] = provider["pk"]!.DeepClone(),
["policy_engine_mode"] = "all",
}, token);
if(!string.Equals(Text(application, "provider"), _provider, StringComparison.Ordinal))
{
throw new InvalidOperationException("The real provider application binding differs from the configured provider.");
}
}
/// <summary>Create a synthetic user with configured groups and its private password.</summary>
private async Task<JsonObject> CreateUserAsync(string usernameKey, string passwordKey, JsonArray groups, CancellationToken token)
{
JsonObject user = await CreateAsync("/api/v3/core/users/", new JsonObject
{
["username"] = Text(_input, usernameKey),
["name"] = "Synthetic native qualification subject",
["email"] = Text(_input, usernameKey) + "@example.invalid",
["is_active"] = true,
["type"] = "internal",
["groups"] = groups,
}, token);
FixtureHttp.Reply response = await _admin.SendAsync("POST", "/api/v3/core/users/" + Text(user, "pk") + "/set_password/",
new JsonObject { ["password"] = Text(_input, passwordKey) }, token);
if(response.Status != HttpStatusCode.NoContent)
{
throw new InvalidOperationException("Synthetic account password configuration failed.");
}
return user;
}
/// <summary>Read bounded provider token metadata scoped to this synthetic application.</summary>
private async Task<JsonArray> GrantsAsync(string kind, CancellationToken token)
{
JsonObject page = FixtureHttp.RequireJson(await _admin.SendAsync("GET", "/api/v3/oauth2/" + kind + "/?provider=" + _provider + "&user=" + _authorizedUser, null, token), HttpStatusCode.OK);
JsonArray records = page["results"]!.AsArray();
if(records.Count is 0 or > 20)
{
throw new InvalidOperationException("The synthetic provider grant set is missing or unexpectedly large.");
}
foreach(JsonNode? node in records)
{
JsonObject record = node!.AsObject();
string provider = record["provider"] is JsonObject nested ? Text(nested, "pk") : Text(record, "provider");
if(!string.Equals(provider, _provider, StringComparison.Ordinal) || !string.Equals(Text(record["user"]!.AsObject(), "pk"), _authorizedUser, StringComparison.Ordinal))
{
throw new InvalidOperationException("The provider grant escaped the synthetic subject scope.");
}
}
return records;
}
/// <summary>Snapshot actual access-grant identities for the renewal assertion.</summary>
internal async Task<HashSet<string>> AccessGrantIdsAsync(CancellationToken token)
=> (await GrantsAsync("access_tokens", token)).Select(node => Text(node!.AsObject(), "pk")).ToHashSet(StringComparer.Ordinal);
/// <summary>Revoke only refresh grants belonging to the owned synthetic provider.</summary>
internal async Task RevokeRefreshGrantsAsync(CancellationToken token)
{
foreach(JsonNode? node in await GrantsAsync("refresh_tokens", token))
{
string id = Text(node!.AsObject(), "pk");
if(!Guid.TryParse(id, out _) && !long.TryParse(id, System.Globalization.CultureInfo.InvariantCulture, out _))
{
throw new InvalidOperationException("Unexpected provider grant identifier.");
}
FixtureHttp.Reply response = await _admin.SendAsync("DELETE", "/api/v3/oauth2/refresh_tokens/" + id + "/", null, token);
if(response.Status != HttpStatusCode.NoContent)
{
throw new InvalidOperationException("Synthetic provider revocation failed.");
}
}
}
/// <summary>Dispose the private provider administration transport.</summary>
public void Dispose() => _admin.Dispose();
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
set -eu
attempt=0
while [ ! -s /data/caddy/pki/authorities/local/root.crt ]; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 60 ]; then
echo 'The fixture public CA certificate did not become ready.' >&2
exit 1
fi
sleep 1
done
# Never copy the key or mount the private CA volume into a client/server.
cp /data/caddy/pki/authorities/local/root.crt /trust/root.crt
chmod 0644 /trust/root.crt
+269
View File
@@ -0,0 +1,269 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// SPDX-License-Identifier: Apache-2.0
package com.kingsrook.qbits.printing;
import java.net.URI;
import java.sql.Connection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
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.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2NativeSessionLoginMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.RedirectStateMetaDataProducer;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionMetaDataProducer;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionRefreshTokenMetaDataProducer;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSessionRefreshToken;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import io.javalin.http.Context;
import io.javalin.http.ForbiddenResponse;
import org.json.JSONArray;
import org.json.JSONObject;
/***************************************************************************
** Opt-in container fixture for the pinned upstream native-session candidate.
** Standard OAuth2 code exchange, session storage and encrypted renewal are used
** unchanged. No installed pairing, password authentication double or printer.
***************************************************************************/
public final class NativeAuthHost extends AbstractQQQApplication
{
private static final String TENANT = "printing-native-dev";
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
private final PrintingService service = new PrintingService(source, TENANT);
/***************************************************************************
** Initialize only the private fixture database and native session tables.
***************************************************************************/
private NativeAuthHost() throws Exception
{
if(port < 1024 || port > 65535)
{
throw new IllegalArgumentException("Unsupported internal fixture port");
}
service.initialize();
try(Connection connection = source.getConnection(); var statement = connection.createStatement())
{
statement.execute("CREATE TABLE IF NOT EXISTS userSession (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, uuid VARCHAR(40) NOT NULL UNIQUE, userId VARCHAR(100), accessToken CLOB, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)");
statement.execute("CREATE TABLE IF NOT EXISTS redirectState (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, state VARCHAR(45) NOT NULL UNIQUE, redirectUri VARCHAR(4096), createDate TIMESTAMP WITH TIME ZONE)");
statement.execute("CREATE TABLE IF NOT EXISTS userSessionRefreshToken (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, sessionUuid VARCHAR(40) NOT NULL UNIQUE, refreshToken CLOB, refreshTokenExpiresAt TIMESTAMP WITH TIME ZONE, sessionStartedAt TIMESTAMP WITH TIME ZONE, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)");
}
}
/***************************************************************************
** Launch the experimental provider host inside its owned container network.
***************************************************************************/
public static void main(String[] args) throws Exception
{
NativeAuthHost host = new NativeAuthHost();
QApplicationLauncher.run(host, new QApplicationLauncherConfig()
.withServerCustomizer(host::configure));
}
/***************************************************************************
** Define the private fixture backend, native OAuth2 provider and printing QBit.
***************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = new QInstance();
instance.setFailOnMetaDataProducerError(true);
instance.setHomeAppName("printing");
instance.setBranding(new QBrandingMetaData().withAppName("QQQ native authentication fixture"));
instance.addBackend(new RDBMSBackendMetaData().withName(backendName).withVendor("h2").withHostName("fixture")
.withDatabaseName("printing-native-auth").withUsername("sa").withPassword(databasePassword).withJdbcUrl(jdbcUrl));
instance.addTable(new UserSessionMetaDataProducer(backendName).produce(instance));
instance.addTable(new RedirectStateMetaDataProducer(backendName).produce(instance));
instance.addTable(new UserSessionRefreshTokenMetaDataProducer(backendName).produce(instance));
String issuer = required("NATIVE_AUTH_ISSUER");
URI issuerUri = URI.create(issuer);
if(!"https".equals(issuerUri.getScheme()) || issuerUri.getHost() == null
|| issuerUri.getRawUserInfo() != null || issuerUri.getRawQuery() != null || issuerUri.getRawFragment() != null || !issuer.endsWith("/"))
{
throw new IllegalArgumentException("An explicit HTTPS fixture issuer ending in slash is required");
}
OAuth2AuthenticationMetaData authentication = new OAuth2AuthenticationMetaData()
.withBaseUrl(issuer).withExternalBaseUrl(issuer)
.withClientId(required("NATIVE_AUTH_CLIENT_ID")).withClientSecret(required("NATIVE_AUTH_CLIENT_SECRET"))
.withScopes("openid profile email offline_access")
.withUserSessionTableName(UserSession.TABLE_NAME).withRedirectStateTableName(RedirectStateMetaDataProducer.TABLE_NAME)
.withRefreshTokenTableName(UserSessionRefreshToken.TABLE_NAME)
.withRefreshTokensEnabled(true).withRefreshTokenSkewSeconds(0).withMaxSessionLifetimeSeconds(3600)
.withRefreshTokenEncryptionKey(required("NATIVE_QQQ_REFRESH_KEY")).withRefreshTokenEncryptionKeyId("fixture-1")
.withNativeSessionLogin(new OAuth2NativeSessionLoginMetaData().withIssuer(issuer)
.withDiscoveryUrl(issuer + ".well-known/openid-configuration")
.withRedirectUris(List.of(required("NATIVE_AUTH_REDIRECT_URI"))));
authentication.withName("printing-native-provider").withSessionStoreEnabled(false)
.withCustomizer(new QCodeReference(PrintingPermissions.class));
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), authentication);
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service))
.produce(instance, "printing");
for(QTableMetaData table : instance.getTables().values())
{
if(backendName.equals(table.getBackendName()))
{
table.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName().toUpperCase(Locale.ROOT)));
for(var field : table.getFields().values())
{
field.withBackendName(field.getName().toUpperCase(Locale.ROOT));
}
}
}
return instance;
}
/***************************************************************************
** Register printing routes behind the fixture TLS proxy.
***************************************************************************/
private void configure(QApplicationJavalinServer server)
{
// Only the owned TLS proxy is published; this HTTP listener remains inside
// the Compose network. Forwarded HTTPS is required even for discovery.
server.withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
.withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(service)))
.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "0.0.0.0";
config.http.maxRequestSize = 1048576;
config.routes.before(this::guardOrigin);
});
}
/***************************************************************************
** Require the configured HTTPS proxy and same-origin cookie mutations.
***************************************************************************/
private void guardOrigin(Context context)
{
if(!origin.getRawAuthority().equalsIgnoreCase(context.header("Host"))
|| !"https".equals(context.header("X-Forwarded-Proto")))
{
throw new ForbiddenResponse("The fixture requires its configured HTTPS proxy origin");
}
String suppliedOrigin = context.header("Origin");
if(suppliedOrigin != null && !origin.toString().equals(suppliedOrigin))
{
throw new ForbiddenResponse("Cross-origin fixture requests are not allowed");
}
if("cross-site".equalsIgnoreCase(context.header("Sec-Fetch-Site"))
|| context.header("X-QQQ-In-Process-Session") != null)
{
throw new ForbiddenResponse("Only standard native session authentication is allowed");
}
boolean unsafe = !Set.of("GET", "HEAD", "OPTIONS").contains(context.method().name());
boolean cookie = context.cookie("sessionUUID") != null || context.cookie("sessionId") != null;
if(unsafe && cookie && suppliedOrigin == null)
{
throw new ForbiddenResponse("Cookie mutations require the configured Origin");
}
}
/***************************************************************************
** Only maps claims after the unchanged standard OAuth2 module authenticates.
***************************************************************************/
public static final class PrintingPermissions implements QAuthenticationModuleCustomizerInterface
{
/***************************************************************************
** Grant the printing admin permission only for the fixture group claim.
***************************************************************************/
@Override
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
{
session.setPermissions(Set.of());
session.withSecurityKeyValue("printing_tenant", TENANT);
Object claims = context.get("jwtPayloadJsonObject");
if(session.getUser() != null && claims instanceof JSONObject payload)
{
JSONArray groups = payload.optJSONArray("groups");
if(groups != null && groups.toList().contains("printing_admin"))
{
session.setPermissions(Set.of("printing_admin"));
}
}
}
}
/***************************************************************************
** Validate the exact root HTTPS origin used by the private TLS fixture.
***************************************************************************/
private static URI httpsOrigin(String value)
{
URI parsed = URI.create(value);
if(!"https".equals(parsed.getScheme()) || parsed.getHost() == null || parsed.getRawUserInfo() != null
|| parsed.getRawQuery() != null || parsed.getRawFragment() != null || !parsed.getRawPath().isEmpty())
{
throw new IllegalArgumentException("A root HTTPS fixture origin without trailing slash is required");
}
return parsed;
}
/***************************************************************************
** Read a required synthetic fixture setting without logging its value.
***************************************************************************/
private static String required(String name)
{
String value = System.getenv(name);
if(value == null || value.isBlank())
{
throw new IllegalArgumentException("Set the synthetic fixture variable " + name);
}
return value;
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
set -eu
# This first request must fail with the ordinary TLS validator, before trust.
dotnet /app/NativeAuth.dll assert-untrusted
test -s /trust/root.crt
cp /trust/root.crt /usr/local/share/ca-certificates/printing-native-fixture.crt
update-ca-certificates >/dev/null 2>&1
exec dotnet /app/NativeAuth.dll test
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
set -eu
# Only this disposable container's JRE trust store is changed. The mounted file
# is the public root certificate; the CA private key is never mounted here.
test -s /trust/root.crt
keytool -importcert -noprompt -alias printing-native-fixture -file /trust/root.crt -cacerts -storepass changeit >/dev/null 2>&1
exec java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.NativeAuthHost
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
set -eu
case "$TEST_RUN" in run-[a-f0-9]*) ;; *) echo 'Invalid owned test run' >&2; exit 2;; esac
mkdir -p "/work/$TEST_RUN/s3"
java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.DevHost --wait-storage
java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' S3PrintingQualification "/work/$TEST_RUN/s3" http://127.0.0.1:9000 "$TEST_RUN"
exec java -Xmx768m -Dlog4j.configurationFile=/app/log4j2.xml -cp '/app/classes:/app/lib/*:/app/fixture' com.kingsrook.qbits.printing.WorkloadFixture "/work/$TEST_RUN" "$WORKLOAD_MODE"
+71
View File
@@ -0,0 +1,71 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# No Docker or HTTP calls: these regressions exercise private bootstrap state.
BeforeAll {
$script:authScript = Join-Path $PSScriptRoot '../Invoke-Authentik.ps1'
$script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef'
}
Describe 'Authentik development bootstrap' {
BeforeEach {
$state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $state | Out-Null
$envFile = Join-Path $state 'compose.env'
$prefix = "PRINTING_DEV_PROJECT=$project`nMINIO_ROOT_USER=synthetic`nQQQ_HTTP_PORT=19081`n"
[IO.File]::WriteAllText($envFile, $prefix)
}
It 'preserves common environment values and creates a matching hashed admin password without console output' {
$output = @(& $authScript -Action Initialize -StateDirectory $state -ProjectName $project)
$output.Count | Should -Be 0
$environment = Get-Content -LiteralPath $envFile -Raw
$environment.StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue
[regex]::Matches($environment, '(?m)^AUTHENTIK_[A-Z_]+=').Count | Should -Be 5
$private = Get-Content -LiteralPath (Join-Path $state 'auth-secrets.json') -Raw | ConvertFrom-Json
$match = [regex]::Match($environment, '(?m)^AUTHENTIK_BOOTSTRAP_PASSWORD_HASH=''pbkdf2_sha256\$1000000\$([^$]+)\$([^'']+)''')
$match.Success | Should -BeTrue
$actual = [Convert]::ToBase64String([Security.Cryptography.Rfc2898DeriveBytes]::Pbkdf2($private.adminPassword, [Text.Encoding]::UTF8.GetBytes($match.Groups[1].Value), 1000000, [Security.Cryptography.HashAlgorithmName]::SHA256, 32))
[string]::Equals($actual, $match.Groups[2].Value, [StringComparison]::Ordinal) | Should -BeTrue
}
It 'does not rotate credentials or append duplicate keys on repeated initialization' {
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
$beforeEnvironment = (Get-FileHash -LiteralPath $envFile).Hash
$beforeSecrets = (Get-FileHash -LiteralPath (Join-Path $state 'auth-secrets.json')).Hash
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
(Get-FileHash -LiteralPath $envFile).Hash | Should -Be $beforeEnvironment
(Get-FileHash -LiteralPath (Join-Path $state 'auth-secrets.json')).Hash | Should -Be $beforeSecrets
}
It 'rejects a changed project or port without overwriting private state' {
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
$before = (Get-FileHash -LiteralPath $envFile).Hash
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project -Port 19011 } | Should -Throw '*different project or port*'
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName 'qbit-printing-dev-aaaaaaaa' } | Should -Throw '*different project or port*'
(Get-FileHash -LiteralPath $envFile).Hash | Should -Be $before
}
It 'rejects partial initialization rather than assigning different secrets to an existing database' {
Add-Content -LiteralPath $envFile -Value 'AUTHENTIK_SECRET_KEY=synthetic'
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*Partial Authentik initialization*'
Test-Path -LiteralPath (Join-Path $state 'auth-secrets.json') | Should -BeFalse
}
It 'rejects missing environment keys after initialization' {
& $authScript -Action Initialize -StateDirectory $state -ProjectName $project
[IO.File]::WriteAllText($envFile, $prefix)
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*incomplete or ambiguous*'
}
It 'withholds malformed private configuration contents' {
[IO.File]::WriteAllText((Join-Path $state 'auth-secrets.json'), '{"private":"synthetic-value-to-withhold"')
{ & $authScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*contents are withheld*'
}
It 'refuses a state directory inside the source checkout before writing' {
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
{ & $authScript -Action Initialize -StateDirectory $repository -ProjectName $project } | Should -Throw '*outside the source checkout*'
}
}
+129
View File
@@ -0,0 +1,129 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Ownership regressions use mocked Docker metadata; no containers or network calls.
BeforeAll {
$script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1'
$tokens = $null
$errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile($entrypoint, [ref]$tokens, [ref]$errors)
if ($errors.Count)
{
throw 'Entrypoint parsing failed.'
}
foreach ($name in @('Assert-OwnedResource', 'Get-RecordedAuthentikPort'))
{
$guard = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true)
. ([scriptblock]::Create($guard.Extent.Text))
}
$script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef'
function docker
{
throw 'Unmocked Docker call refused.'
}
}
Describe 'Development lifecycle ownership' {
BeforeEach {
$script:wrongResource = ''
Mock docker {
$global:LASTEXITCODE = 0
$command = $args -join ' '
if ($command.StartsWith('ps -aq', [StringComparison]::Ordinal))
{
return 'container-id'
}
if ($command.StartsWith('network ls', [StringComparison]::Ordinal))
{
return 'network-id'
}
if ($command.StartsWith('volume ls', [StringComparison]::Ordinal))
{
return 'volume-id'
}
if ($script:wrongResource -and $command.Contains($script:wrongResource, [StringComparison]::Ordinal))
{
return 'another-project'
}
return $script:project
}
}
It 'accepts only consistently labeled containers, networks and volumes' {
{ Assert-OwnedResource } | Should -Not -Throw
Should -Invoke docker -Times 6 -Exactly
}
It 'rejects a foreign container before any cleanup' {
$script:wrongResource = 'container-id'
{ Assert-OwnedResource } | Should -Throw '*Container ownership mismatch*'
Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0
}
It 'rejects a foreign network before any cleanup' {
$script:wrongResource = 'network-id'
{ Assert-OwnedResource } | Should -Throw '*Network ownership mismatch*'
Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0
}
It 'rejects a foreign volume before any cleanup' {
$script:wrongResource = 'volume-id'
{ Assert-OwnedResource } | Should -Throw '*Volume ownership mismatch*'
Should -Invoke docker -ParameterFilter { ($args -join ' ') -match 'down|remove|prune' } -Times 0
}
It 'refuses an existing unowned state directory before contacting Docker' {
{ & $entrypoint -Action Stop -StateDirectory $TestDrive } | Should -Throw '*lacks this tool ownership record*'
Should -Invoke docker -Times 0
}
It 'rejects persistent experimental startup before any state creation' {
$path = Join-Path $TestDrive 'experimental-start'
{ & $entrypoint -Action Start -ExperimentalNativeAuth -StateDirectory $path } | Should -Throw '*Test-only profile*'
Test-Path -LiteralPath $path | Should -BeFalse
Should -Invoke docker -Times 0
}
It 'does not repurpose a saved standard environment for experimental authentication' {
$path = Join-Path $TestDrive 'standard-environment'
[IO.Directory]::CreateDirectory($path) | Out-Null
@{sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')); project = $project; authentik = $false } |
ConvertTo-Json | Set-Content (Join-Path $path 'owner.json')
{ & $entrypoint -Action Test -ExperimentalNativeAuth -StateDirectory $path } | Should -Throw '*cannot be repurposed*'
Should -Invoke docker -Times 0
}
It 'refuses a forged project name before contacting Docker' {
$path = Join-Path $TestDrive 'forged'
[IO.Directory]::CreateDirectory($path) | Out-Null
@{sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')); project = 'unrelated'; authentik = $false } | ConvertTo-Json | Set-Content (Join-Path $path 'owner.json')
{ & $entrypoint -Action Stop -StateDirectory $path } | Should -Throw '*State ownership mismatch*'
Should -Invoke docker -Times 0
}
It 'restores a nondefault Authentik port from saved state' {
$script:envFile = Join-Path $TestDrive 'ports.env'
[IO.File]::WriteAllText($envFile, "QQQ_HTTP_PORT=19081`nAUTHENTIK_HTTP_PORT=19011`n")
Get-RecordedAuthentikPort | Should -Be 19011
}
It 'rejects ambiguous saved port configuration' {
$script:envFile = Join-Path $TestDrive 'ambiguous.env'
[IO.File]::WriteAllText($envFile, "AUTHENTIK_HTTP_PORT=19010`nAUTHENTIK_HTTP_PORT=19011`n")
{ Get-RecordedAuthentikPort } | Should -Throw '*missing or invalid*'
}
It 'rejects a state ancestor junction or symlink before writing or contacting Docker' {
$target = Join-Path $TestDrive 'target'
$link = Join-Path $TestDrive 'linked'
[IO.Directory]::CreateDirectory($target) | Out-Null
if ($IsWindows)
{
New-Item -ItemType Junction -Path $link -Target $target -ErrorAction Stop | Out-Null
}
else
{
New-Item -ItemType SymbolicLink -Path $link -Target $target -ErrorAction Stop | Out-Null
}
try
{
{ & $entrypoint -Action Start -StateDirectory (Join-Path $link 'new-state') } | Should -Throw '*symbolic link or reparse point*'
Test-Path -LiteralPath (Join-Path $target 'new-state') | Should -BeFalse
Should -Invoke docker -Times 0
}
finally
{
[IO.Directory]::Delete($link)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# No Docker, TLS, provider or QQQ calls; only private input and result validation.
BeforeAll {
$script:nativeScript = Join-Path $PSScriptRoot '../Invoke-NativeAuth.ps1'
$script:project = 'qbit-printing-dev-0123456789abcdef0123456789abcdef'
$script:requiredChecks = @('untrusted-tls-refused', 'typed-native-grant', 'real-provider-login', 'wrong-origin-cookie-denied', 'permission-denied-user', 'logout-replay-denied', 'server-refresh-after-expiry', 'provider-revocation-denied')
}
Describe 'Experimental native provider qualification state' {
BeforeEach {
$state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $state | Out-Null
$envFile = Join-Path $state 'compose.env'
$prefix = "PRINTING_DEV_PROJECT=$project`nMINIO_ROOT_USER=synthetic`n"
[IO.File]::WriteAllText($envFile, $prefix)
@{ project = $project; clientId = $project; bootstrapToken = 'synthetic-admin'; clientSecret = 'synthetic-client'; userPassword = 'synthetic-user' } |
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $state 'auth-secrets.json')
$script:report = @{
status = 'PASS'; scope = 'DEVELOPMENT_ONLY_UNRELEASED'; project = $project
coreSource = 'ecf0f550a0357cec6387c2b54efca128a405a629'
clientSource = '346fc285198908c47a2ee9db7b9adb53d5e88c3c'; checks = $requiredChecks
}
$script:resultFile = Join-Path $state 'native-auth-result.json'
}
It 'preserves common state and creates only the exact owned destinations without console secrets' {
$output = @(& $nativeScript -Action Initialize -StateDirectory $state -ProjectName $project)
$output.Count | Should -Be 0
(Get-Content -LiteralPath $envFile -Raw).StartsWith($prefix, [StringComparison]::Ordinal) | Should -BeTrue
$inputData = Get-Content -LiteralPath (Join-Path $state 'native-input.json') -Raw | ConvertFrom-Json
$inputData.qqqOrigin | Should -Be 'https://qqq.printing.internal'
$inputData.callback | Should -Be 'https://callback.printing.internal/oauth/callback'
$inputData.project | Should -Be $project
(Get-Content -LiteralPath $script:resultFile -Raw | ConvertFrom-Json).status | Should -Be 'NOT_RUN'
$before = (Get-FileHash -LiteralPath $envFile).Hash
{ & $nativeScript -Action Initialize -StateDirectory $state -ProjectName $project } | Should -Throw '*fresh owned environment*'
(Get-FileHash -LiteralPath $envFile).Hash | Should -Be $before
}
It 'refuses credentials owned by another project before writing native input' {
{ & $nativeScript -Action Initialize -StateDirectory $state -ProjectName 'qbit-printing-dev-aaaaaaaa' } | Should -Throw '*does not match*'
Test-Path -LiteralPath (Join-Path $state 'native-input.json') | Should -BeFalse
}
It 'accepts the exact source and complete bounded report' {
$script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile
& $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project | Should -Match 'candidate is unpublished'
}
It 'rejects a passing report from a different source' {
$script:report.coreSource = '0000000000000000000000000000000000000000'
$script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*exact source*'
}
It 'rejects missing renewal evidence instead of treating successful login as complete' {
$script:report.checks = @($requiredChecks | Where-Object { $_ -ne 'server-refresh-after-expiry' })
$script:report | ConvertTo-Json | Set-Content -LiteralPath $script:resultFile
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*required checks*'
}
It 'rejects malformed or oversized reports without echoing their contents' {
[IO.File]::WriteAllText($script:resultFile, '{"private":"do-not-echo"')
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*contents are withheld*'
[IO.File]::WriteAllText($script:resultFile, ('x' * 65537))
{ & $nativeScript -Action VerifyResult -StateDirectory $state -ProjectName $project } | Should -Throw '*bounded size*'
}
}
+51
View File
@@ -0,0 +1,51 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
BeforeAll {
$path = Join-Path $PSScriptRoot '../Invoke-Dev.ps1'
$tokens = $null
$errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw 'Entrypoint parsing failed.' }
$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConvertTo-ProviderErrorSummary' }, $true)
. ([scriptblock]::Create($function.Extent.Text))
}
Describe 'Private provider diagnostic redaction' {
It 'retains source coordinates but excludes exception values, locals and request data' {
$record = @{event = 'secret-request'; exception = @(@{exc_type = 'KeyError'; exc_value = 'secret-value'; frames = @(
@{filename = '/authentik/flows/views/executor.py'; lineno = 42; name = 'post'; locals = @{password = 'secret-password'}; source = 'secret-source'},
@{filename = '/outside/secret-path.py'; lineno = 3; name = 'unsafe'}
)
})
} | ConvertTo-Json -Depth 8 -Compress
$result = ConvertTo-ProviderErrorSummary @($record, 'secret-unstructured-line')
$result.exceptionTypes | Should -Be @('KeyError')
$result.frames.Count | Should -Be 1
$result.frames[0].function | Should -Be 'post'
$result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret|password|outside|locals|source'
}
It 'bounds frames and does not retain malformed or query-bearing coordinates' {
$frames = @(1..20 | ForEach-Object { @{filename = '/authentik/test.py'; lineno = $_; name = 'get'} })
$record = @{exception = @(@{exc_type = 'ValueError'; frames = $frames})} | ConvertTo-Json -Depth 8 -Compress
(ConvertTo-ProviderErrorSummary @($record)).frames.Count | Should -Be 12
$bad = @{exception = @(@{exc_type = 'secret?Error'; frames = @(@{filename = '/authentik/test.py?secret'; lineno = 1; name = 'get'})})} | ConvertTo-Json -Depth 8 -Compress
$result = ConvertTo-ProviderErrorSummary @($bad, '{broken', 'null')
$result.exceptionTypes.Count | Should -Be 0
$result.frames.Count | Should -Be 0
}
It 'records only fixed known warning flags from unstructured output' {
$result = ConvertTo-ProviderErrorSummary @('Invalid client identifier secret-query', 'Non-applicable authentication flow secret', 'failed to resolve application secret')
$result.warnings.invalidClientIdentifier | Should -BeTrue
$result.warnings.nonApplicableAuthenticationFlow | Should -BeTrue
$result.warnings.failedToResolveApplication | Should -BeTrue
$result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret'
}
It 'retains fixed DRF exception names without their messages' {
$record = @{exception = @(@{exc_type = 'PermissionDenied'; exc_value = 'secret-csrf-value'; frames = @()})} | ConvertTo-Json -Depth 5 -Compress
$result = ConvertTo-ProviderErrorSummary @($record)
$result.exceptionTypes | Should -Be @('PermissionDenied')
$result | ConvertTo-Json -Depth 6 | Should -Not -Match 'secret'
}
}
+63
View File
@@ -0,0 +1,63 @@
#Requires -Version 7.4
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
BeforeAll {
$script:entrypoint = Join-Path $PSScriptRoot '../Invoke-Dev.ps1'
function docker { throw 'Unmocked Docker call refused.' }
}
Describe 'Early workload client failure' {
BeforeEach {
$script:state = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N'))
@{success = $false; stage = 'native_login_and_channels'; error = 'QqqSessionException'; reason = 'secret-value https://private.invalid/?token=secret'; completed = 0} | ConvertTo-Json | Set-Content (Join-Path $TestDrive 'client-fixture.json')
Mock docker {
$global:LASTEXITCODE = 0
if ($args[0] -eq 'compose')
{
if ($args -contains 'ps')
{
if ($args[-1] -eq 'test-client') { return ('c' * 64) }
if ($args[-1] -eq 'test-server') { return ('b' * 64) }
}
return
}
if ($args[0] -eq 'inspect')
{
if ($args[1] -eq ('c' * 64)) { return 'false|17' }
throw 'SERVER_WAIT_REACHED'
}
if ($args[0] -eq 'cp')
{
$fixtureRoot = [IO.Directory]::GetParent([IO.Directory]::GetParent([IO.Directory]::GetParent($args[-1]).FullName).FullName).FullName
Copy-Item -LiteralPath (Join-Path $fixtureRoot 'client-fixture.json') -Destination $args[-1]
return
}
if ($args[0] -in @('ps', 'network', 'volume')) { return }
throw 'Unexpected fake Docker operation.'
}
}
It 'surfaces the client failure before server waiting and still cleans owned containers' {
{ & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at native_login_and_channels (QqqSessionException)*'
Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0
Should -Invoke docker -ParameterFilter { $args[0] -eq 'compose' -and $args -contains 'rm' -and $args -contains '--stop' } -Times 1 -Exactly
$result = Get-ChildItem -LiteralPath $script:state -Filter result.json -Recurse
(Get-Content -LiteralPath $result.FullName -Raw | ConvertFrom-Json).reason | Should -Be 'secret-value https://private.invalid/?token=secret'
}
It 'does not print unrecognized stage or exception text' {
@{success = $false; stage = 'secret-stage https://private.invalid/'; error = 'secret-error'} | ConvertTo-Json | Set-Content (Join-Path $TestDrive 'client-fixture.json')
{ & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at unknown (UnknownFailure)*'
Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0
}
It 'fails safely for <Kind> diagnostics and still cleans owned containers' -TestCases @(
@{Kind = 'malformed'; Content = '{secret-malformed-json'},
@{Kind = 'null'; Content = 'null'},
@{Kind = 'oversized'; Content = (@{success = $false; stage = 'input'; error = 'JsonException'; reason = ('s' * 65536)} | ConvertTo-Json -Compress)}
) {
param($Content)
$Content | Set-Content (Join-Path $TestDrive 'client-fixture.json')
{ & $entrypoint -Action Test -StateDirectory $script:state } | Should -Throw '*Workload client failed at unknown (UnknownFailure)*'
Should -Invoke docker -ParameterFilter { $args[0] -eq 'inspect' -and $args[1] -eq ('b' * 64) } -Times 0
Should -Invoke docker -ParameterFilter { $args[0] -eq 'compose' -and $args -contains 'rm' -and $args -contains '--stop' } -Times 1 -Exactly
}
}
+56
View File
@@ -0,0 +1,56 @@
# Java compiler safety
The repository compiler runs Error Prone 2.51.0 and NullAway 0.14.3 for
`com.kingsrook.qbits.printing`, including test sources. NullAway findings are
errors; compiler warnings fail through `-Werror` and Maven `failOnWarning`.
This implements K22-CODE-JV-06 and the compiler portion of K22-CODE-02. It is
not a claim that every unrelated coding-standard requirement is complete.
The configuration retains Java 21, compiler plugin 3.14.1, `proc:none`, the
existing parent and the frozen QQQ dependency tuple. The explicit compiler
plugin path loads the analyzers even with annotation processing disabled.
JSpecify 1.0.0 is declared directly for nullable contracts; it was already in
the runtime closure. NullAway's contract annotations are provided-only.
Neither analyzer implementation is an application runtime dependency.
Run the usual quality gate:
```sh
./mvnw -B -ntp -Pui-demo verify
```
For a compiler-configuration change, also run the existing bounded guard locally:
```sh
python3 .github/scripts/check-java-compiler.py
```
The guard copies the actual POM compiler block into fresh, isolated modules
under `target/compiler-guard`. Both main and test sources have a positive
probe and negative probes for NullAway, Error Prone's `DeadException`, and a
deprecated call rejected by warnings-as-errors. A random Maven failure does
not satisfy a negative probe: the expected diagnostic must be present.
Logs and a result manifest are written under `target/compiler-guard`. The former GitHub workflow ran this probe explicitly; primary Gitea verification enforces the POM compiler checks during the Maven build.
No artifact is installed or published. Optional `--maven`, `--settings`,
`--local-repository` and `--offline` arguments support an isolated local cache.
Null annotations describe existing optional inputs and transitions; they do
not convert absent data into required configuration. Existing lifecycle
checks provide checked initializer contracts. HELLO-dependent work receives
the validated payload explicitly. Scheduled retention captures its service
under the existing missing-configuration error contract. A missing realtime
QQQ instance now fails descriptively at revalidation, without moving host
registration timing or caching user authority.
Narrow member suppressions retain required framework override return types,
exact socket identity fencing and the framework's ordered presentation enum.
Two private accessors document the storage/page lifecycle invariants that
cannot be inferred from separate nullable fields. They preserve the existing
use-after-finish or misconfigured-storage failure rather than inventing valid
null operations. Suppressions are not package-wide, and tests remain analyzed.
The migration separates redundant return-expression parentheses from null
contracts and compiler configuration for review. Public JVM descriptors and
the resolved runtime JAR set are verified against the unchanged baseline.
The ordinary test suite intentionally skips the separately opt-in 10,000-job
qualification unless `printing.qualification.load=true` is supplied.
+4 -4
View File
@@ -10,7 +10,7 @@
| `PrintingUiDemo` | Isolated in-memory synthetic data, mock administrator, no device routes | Native UI demonstration only |
| `PrintingLocalHost` | Durable H2 shared by native QQQ and device routes, real password/session authentication | Authenticated local application |
The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions in QQQ RC20.
The local host adds its own `printing_admin_user` and `printing_admin_session` tables. It preserves existing printing tables, device credential hashes, receipt history and tenant identity. Physical table/column names are mapped to the existing H2 case convention; it does not rename an uppercase database or change `DATABASE_TO_UPPER`. Every host instance uses a distinct internal backend name to avoid cross-instance provider-cache collisions observed during RC.20 development.
## Required configuration
@@ -36,7 +36,7 @@ $dependencies = (Get-Content target/local-host-classpath.txt -Raw).Trim()
& "$env:JAVA_HOME/bin/java.exe" -cp "target/classes;$dependencies" com.kingsrook.qbits.printing.PrintingLocalHost
```
Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. The existing Windows connector continues using the same server origin and device token; it does not receive the browser password or management token.
Set the required environment values securely before launch. Maven contract tests require the documented sibling protocol fixture checkout or `printing.protocol.fixtures` override. Browse to `http://localhost:18081/` (or the configured port) and use the native username/password screen. Configure that origin in the Windows connector Connection settings and sign in through the discovered QQQ authentication flow. Preserve an existing pairing when upgrading; the native path requires the current user session as well as device proof. The connector does not receive the management token.
This command runs the host in the foreground. `QApplicationLauncher` owns graceful HTTP/scheduler shutdown. It does not install an operating-system service or auto-start registration; the deploying operator must provide supervision, protected credential loading, logs and backups for unattended use.
@@ -60,7 +60,7 @@ Existing facility IDs are retained. The standalone enrollment form offers the na
QQQ supplies password verification, failed-sign-in throttling, HttpOnly sessions, session expiry and logout. The local authorization subclass calls native verification first and maps only the configured account to printing permissions. Other valid accounts cannot gain those permissions. Native QQQ permission and tenant checks still apply to every table/process.
The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit bearer/device API calls without ambient cookies remain usable from the connector. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service.
The host rejects non-loopback Host headers, cross-origin Origin headers and cross-site browser requests. Cookie-authenticated unsafe requests require an exact same-origin Origin header, including logout and native process actions. Explicit API calls without ambient cookies still require their normal route credentials; native bound devices use QQQ session authentication plus device proof. Use one loopback hostname consistently in the browser; `localhost` and `127.0.0.1` are different browser origins. These controls do not turn a local HTTP host into an Internet-facing service.
## Retention, storage and remaining deployment work
@@ -68,4 +68,4 @@ The native Quartz scheduler runs the existing bounded cleanup process every five
This local host deliberately leaves the S3 binding unconfigured. Native settings report legacy database storage. New and existing documents therefore remain in the database; merely running this host does not move documents to S3. A deployed application must supply its existing dedicated `printing_*` S3 table/backend and scheduler configuration, with bucket/IAM/tenant separation described in [storage and retention](STORAGE-AND-RETENTION.md). The host must retain historical storage bindings when changing configuration.
Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. Record Files source-version/actor integration remains pending its concrete public contract; standalone printing delivery storage must not own or delete canonical attachments.
Remote HTTPS deployment, real S3/IAM, production OAuth roles, PostgreSQL and operating-system supervision remain separate qualification work. The optional [retained-source facade](RETAINED-PRINT-FACADE.md) requires a host-supplied source authority; standalone printing delivery storage must not own or delete canonical attachments.
+12 -8
View File
@@ -24,7 +24,7 @@ Normal progression is QUEUED → CLAIMED → RECEIVED → SUBMITTING → SPOOLED
Use a stable business idempotency key for retries of the same request. A changed target/document/options with the same key is a conflict. A deliberately new print requires a new key. The initial implementation does not automatically reclaim RECEIVED/SUBMITTING jobs or resend ambiguous output; inspect the queue and physical result before deciding to print again.
The pending 0.1.4 implementation stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade.
Version 1.0.0 stores new documents through the host's configured dedicated QQQ S3 storage table; existing inline database content remains readable. Administration defaults to 365 days after server-observed successful completion, with bounded scheduled cleanup when a host scheduler is configured. Active, failed, uncertain and unknown-completion legacy jobs remain protected. See [storage and retention](STORAGE-AND-RETENTION.md) for bucket separation, recovery and versioning details. No live storage migration or cleanup is implied by a source upgrade.
## Admission capacity and inventory history
@@ -49,16 +49,17 @@ Inventory snapshots remain limited to 200 entries. Rotating IDs cannot bypass cu
## Device controls and durable action receipts
The native client API is the registered `printing` extension, semantic version
`1.0.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
`2.4.0`, under `/qqq/v1/qbit/printing`. Discover it through QQQ's version endpoint;
its generated OpenAPI fragment is `src/main/resources/openapi/qqq-v1/printing.yaml`.
The contract test checks this fragment against the registered endpoint specs and
the initial semantic compatibility baseline. Core QQQ session authentication and
credential scopes run before printing operations; the application does not issue
another kind of user login token.
Every native operation requires the authenticated QQQ principal, `printing_admin`,
the configured tenant security key, and applicable native table, process and row
access. Device operations additionally require `X-QQQ-Device-Id` and
Native administration and device operations require the authenticated QQQ principal,
`printing_admin`, the configured tenant security key, and applicable native table,
process and row access. The separate [operator catalog and submission API](OPERATOR-CATALOG.md)
also permits `printing_operator`, with its narrower destination/process checks. Device operations additionally require `X-QQQ-Device-Id` and
`X-QQQ-Device-Token` as secondary proof. Enrollment at `/devices/enroll` validates
the user's facility access before consuming the enrollment code and atomically
binds the device to the core user's stable identity. A legacy unbound device can
@@ -94,7 +95,7 @@ reach that context; printing does not assert that an upstream cached session is
fresh. Cookie-authenticated unsafe calls must also satisfy the host's Origin/CSRF
policy. The local host requires its canonical same-origin authority.
In the qualified QQQ 4.1.0-RC.20 runtime, OAuth2 `sessionStoreEnabled` defaults
In the recorded RC.20 authentication qualification, OAuth2 `sessionStoreEnabled` defaults
to `false`. Ordinary requests and session resume rerun the configured
`QAuthenticationModuleCustomizerInterface` hooks; the host can load current
permissions, tenant keys, and account status from its authoritative directory.
@@ -151,7 +152,8 @@ seven days; an unchanged old request still expires after its receipt is removed.
Remote inventory refresh and owned spool cancellation require advertised agent
capabilities, expire after 15 minutes, and have a limit of 100 pending commands
per device. Polls deliver at most 20 commands. The retained action history is
per device. Control delivery is bounded to 20 commands per batch over the real-time
channel; there is no timed job/control polling fallback. The retained action history is
limited to 1,000 records per device, with one additional reserved revocation;
eligible old terminal records are reclaimed before admission. Capacity returns
429 explicitly. History pages contain at most 200 records and use the preceding
@@ -180,4 +182,6 @@ cancellation request, not that paper was withdrawn or a physical print was undon
## Release qualification
Prior released checks include user-confirmed laser PDF and Zebra ZPL/PDF output with correct machine driver settings. Audit source checks cover H2, native processes, host HTTP registration, synthetic storage and small real connection pools. Follow-up qualification includes live S3/IAM, PostgreSQL, production host authentication, upgrade/migration paths, multiple QQQ nodes, prolonged disconnects and a broader printer/driver set. Current prerelease artifacts must not be described as certifying those scenarios.
The stable 1.0.0 library and its matching release/registry bytes are published; see [release evidence](RELEASES.md). Local checks cover H2 persistence, native QQQ operations, real HTTP/WebSocket transport, S3-compatible storage and bounded job histories. User-confirmed physical output includes laser PDF and Zebra ZPL/PDF.
A deployment still supplies and validates its own database, S3/IAM, authentication mapping, supervision and printer drivers. PostgreSQL and multiple-node operation are not qualified by the local H2/MinIO fixture. The default real-time bus is in-process; consult [host integration](REALTIME.md) before scaling hosts. These are deployment boundaries, not pending 1.0 publication tasks.
+70
View File
@@ -0,0 +1,70 @@
# Operator catalog and queue media
QQQ Printing 1.0.0 includes native printing extension 2.3.0, paired with Windows
connector 1.0.1. Extension 2.2.0 introduced rendering; 2.3.0 adds this operator/media API. Existing administrator and device routes keep
their permissions; this is an additive operator API, not a device bearer API.
Hosts grant `printing_operator` and the `printing_tenant` security key. The native
`printingSubmit` process remains the domain write permission. Current device and
printer table/record security, credential scopes and client-channel ceilings apply
on every operation. Generic job/command history, table writes, enrollment and
device management remain administrator-only. Hosts may add destination locks.
Native callbacks cannot replace the initiating actor, session, instance, service
or row-security context. Submission rechecks current process, credential, channel
and destination authority after loading/storage callbacks and before commit.
The backend process is hidden and has no base64-entry form. Echo obtains its user
selections from the catalog; backend metadata also references native printer and
format possible-value sources.
- `GET /qqq/v1/qbit/printing/operator/printers?limit=50&after=...` returns at most
200 authorized approved/exposed printers, in stable printer-row-ID order. The
opaque next cursor advances over at most `limit` candidate rows even if parent
device security hides every candidate. Continue until `nextCursor` is null.
- `POST /qqq/v1/qbit/printing/operator/jobs` accepts `requestId`, `printerRowId`,
`title`, `format` (`PDF` or `ZPL`), `contentBase64` and optional `options`. The
native printer row determines the device and opaque Windows queue identity.
`requestId` is 1–128 characters from letters, digits, `.`, `_`, `:`, `-`.
The authenticated actor and tenant scope the stable key; callers cannot supply
actor identity or an internal idempotency key. The response is the existing job
receipt after durable commit. Changed requests conflict instead of reprinting.
- `GET /qqq/v1/qbit/printing/operator/requests/{requestId}` returns only that
actor's exact accepted request, or 404. It does not enqueue, read document bytes,
or initialize settings. Current destination read authority remains required;
later loss of exposure alone does not erase the acceptance receipt.
Lookup checks the original accepted native printer-row ID. Deleting that row
and creating another with the same device/queue pair does not transfer history.
Each catalog entry has `printerRowId`, `deviceId`, `printerId`, effective `name`,
`deviceName`, `status`, `formats`, `mediaProfiles`, and optional `description` and
`location`. These names are presentation; the stable row identity selects a queue.
Media profiles report actual Windows queue defaults. Their typed facts are `id`,
`revision` (lowercase SHA-256), `name`, `widthMicrons`, `heightMicrons`, `dpiX`,
`dpiY`, `source: WINDOWS_QUEUE_DEFAULT`, `applyMode: DRIVER_DEFAULT`. Unknown or
unsupported driver facts produce an empty list. The initial client reports one
configured default; the wire bound is 32. Dimensions are 1–2,000,000 microns and
DPI is 1–9600. Names are at most 512 characters. Unknown additive reported facts
are ignored when canonicalizing; known required facts remain validated. Arbitrary
page-size values are never interpreted as a Windows stock change.
Profile and selection IDs share the protocol's 128-character URL-safe ID bound.
Select exact reported media with `options.mediaProfile: {id, revision}`. The
selection is immutable job intent and accepts no extra fields. New submissions
reject stale/missing profiles before admission/storage writes; exact accepted
replays remain accepted. The capable Windows agent rechecks its actual current
default before spooling. Selected-media dispatch requires the current agent
capability `printer-media-v1`, including after reconnect/downgrade. Legacy
inventory without profiles clears stale media facts. Omitted selection preserves
ordinary existing queue behavior. An explicit job paper size instead uses both
`paperWidthMm` and `paperHeightMm` (positive, at most 2000 mm); it cannot be
combined with `mediaProfile`. The matching connector applies that job paper
without changing the machine-wide queue defaults. Select media matching the
loaded stock and calibrate the physical printer after changing label sizes.
Generated/retained source authorization belongs to the configured source owner.
The retained facade shares this operator permission and destination boundary; it
preserves its atomic accepted source/actor/request binding. Barcode fit and
encoding remain the renderer's job.
`QUEUED` is durable acceptance; `SPOOLED` means spooler acceptance. Neither is
physical output confirmation. These tests use synthetic bytes and no real printer.
+104
View File
@@ -0,0 +1,104 @@
# Server qualification — 2026-11-01
The published server **0.3.0-rc.1** and published Windows **0.3.1-rc.1** Core runtime
completed the following isolated qualification. No live server, installed service,
real document, physical printer or external cloud bucket was changed.
## Network workload
These measurements describe the earlier released-artifact fixture runs. The
portable [Docker developer tooling](../dev/README.md) records its own per-run
measurements and includes separate default smoke and opt-in burst commands.
The existing Java/.NET interoperability fixture was extended with synthetic work.
It used the actual QQQ table-authenticated host, native session/device binding,
WebSocket channel, released Core `PrintWorker`, HTTPS-client code over explicitly
allowed loopback HTTP, file downloads, and WebSocket receipt acknowledgements.
Only the physical printer and local fixture journal were substitutes. The server
persisted real SQL jobs and receipts. Admission limits remained enabled.
| Check | Observed result |
| --- | --- |
| Burst workload | 10,000 jobs; two tenants; eight devices |
| Documents | 1,021-byte synthetic ZPL; exact downloaded bytes checked |
| Completion | 10,000 SPOOLED rows; 30,000 durable event rows; no pending work |
| Replay | 400 submit and 400 receipt replays; no duplicates |
| Isolation | Foreign device binding and tenant job access refused |
| Accounting | Retained bytes exact; no orphan upload rows |
| Duration | 96.12 seconds including setup; about 104 jobs/second |
| Maximum backlog | 7,798 committed jobs; final backlog zero |
| Working set | Server peak 680 MiB; client peak approximately 85 MiB |
| Cold persistence | 10,000 rows present; read-only check left DB unchanged |
Burst enqueue-to-terminal-ACK latency was p50 43.30 seconds, p95 62.84 seconds,
p99 64.66 seconds. These values include intentional queue backlog; they are not
idle notification latency.
A separate **40-job one-at-a-time** run measured:
| Interval | Median | 95th percentile |
| --- | ---: | ---: |
| Received notification to claim completion | 8.72 ms | 27.18 ms |
| Received notification to verified download completion | 29.74 ms | 49.07 ms |
| Enqueue to durable terminal receipt acknowledgement | 49.65 ms | 76.56 ms |
The fixture does not periodically poll for jobs. A received notification wakes a
finite claim/drain operation. Liveness PING messages do not query jobs. These are
HTTP/WS loopback results, **not HTTPS/WSS, remote network, physical print-speed,
large-document sustained throughput, or long-duration soak qualification**.
The fixture journal is not the installed Windows SQLite journal; its recovery
behavior is covered separately by the Windows tests.
## Existing storage path, current release
The existing S3 qualification harness was rerun against this exact published
server and its unchanged 204-JAR runtime. All **65 checks / six document round
trips** passed through native QQQ `StorageAction`, `S3StorageAction`, and AWS SDK
against one disposable local MinIO container. The run includes actual multipart
upload above 5 MiB, exact length/hash/MIME, tenant namespaces, immutable-reference
validation, stable replay, completion-age retention, and protection of active,
failed, uncertain, fresh, foreign and unrelated objects. The owned container and
its ephemeral data were removed after identity verification.
This is S3-compatible data-plane evidence. It does not qualify AWS IAM, remote
TLS, bucket policies, versioning/Object Lock, or a configured production storage
target. The existing platform-specific key-layout binding guard remains required;
no shared QQQ library or cross-OS migration behavior was changed.
## Repeatable service regression
`PrintingWorkloadTest` is an opt-in file-backed regression, separate from the
network fixture. It runs 10,000 real service lifecycles through four pooled SQL
connections, tests replay and tenant pagination, verifies exact event/byte counts,
and closes every connection before reopening without schema initialization.
```powershell
./mvnw.cmd -Pui-demo -Dprinting.qualification.load=true verify
```
The protocol fixtures must be present at the usual sibling checkout; alternatively
set `-Dprinting.protocol.fixtures=/path/to/reviewed/fixtures`.
The full gate passed **119 tests, zero skips and zero Checkstyle violations**.
A subsequent exact event-count assertion improvement passed its focused workload
rerun. Default builds skip this explicit workload test; ordinary regression tests
remain unchanged. The separate development workflow runs cross-platform script
checks and the real 40-job/S3 Docker smoke on pull requests and main pushes; its
experimental native-auth run is manual. Hosted results must be checked on the
submitted commit. No application implementation or dependency changed.
## Scope boundaries
The user's current development/test target is local Docker. AWS/account/IAM or
remote deployment provisioning is not a prerequisite for this change.
The separate [experimental native-auth profile](../dev/native-auth/README.md)
passed real Authentik login, denied-user/wrong-Origin checks, logout, short token
renewal and provider revocation using container-private HTTPS and the pinned
unreleased QQQ API 1.9 candidate. It does not upgrade this regression's released
RC.20 dependency or qualify the installed Windows browser callback. The workload
and S3 measurements above remain HTTP/WS and MinIO evidence, respectively.
Long-duration soak, mid-session account/role changes and sustained physical
throughput remain separate from these bounded tests and previously confirmed
individual physical prints. No application implementation, dependency or
published package version changes in this test/developer-tooling change.
+29 -7
View File
@@ -1,9 +1,10 @@
# Real-time printing host integration
Release candidate [0.3.0-rc.1](https://github.com/QRun-IO/qbit-printing/releases/tag/v0.3.0-rc.1)
implements printing extension **2.0.0** (wire envelope v1). Upgrade the server
before the matching Windows connector; a real-time connector rejects a server that advertises only extension
1.x. There is no timed job/control polling fallback.
QQQ Printing 1.1.2 (the released library is [1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2))
implements native printing extension **2.4.0** (wire envelope v1), paired with Windows
connector **1.2.1**; connector 1.0.1 remains compatible. Upgrade the server before the connector; a real-time connector
rejects a server that advertises only extension 1.x. There is no timed job/control
polling fallback.
## Connection and authorization
@@ -97,13 +98,34 @@ notifications to one another.
The existing `/printing/v1` routes remain explicit legacy diagnostic compatibility.
They are not a real-time fallback and cannot authorize a native bound device.
The full message and settings contract is in
[the shared protocol](https://github.com/QRun-IO/qqq-print-protocol).
[the shared protocol](https://git.kof22.com/QRun-IO/qqq-print-protocol).
## Printing API major upgrade
The extension is **2.0.0** because native HTTP claims now require a synchronized
Extension **2.0.0** introduced the major-version change because native HTTP claims require a synchronized
WebSocket. A published 1.0 HTTP-only connector no longer meets that precondition;
this is a breaking behavior change even though request bodies remain wire v1.
The QQQ framework API `/qqq/v1`, extension path `/qbit/printing` and WebSocket
subprotocol `qqq-printing.v1` remain unchanged. Upgrade server and client together;
an older client must report upgrade-required instead of using polling fallback.
an incompatible older client must report upgrade-required instead of using polling fallback.
Extension 2.3.0 retained that requirement and added operator/media support; 2.4.0
adds printer approval over the socket (below). The product/library version is
independently 1.1.0.
## Printer approval (extension 2.4.0)
A HELLO may carry up to five capabilities; the fifth, `printer-approval-v1`, is
also accepted and stored by the HTTP control poll. Only on a connection that
advertised it, the server sends a `PRINTERS` frame
(`{ "printers": [ { "printerId", "enabled" } ] }`) with the complete approval
state of every printer in the device's latest accepted inventory: after the ACK
of each accepted INVENTORY, and whenever a printer's `enabled` changes for that
device through any path (the admin route, the native approve/disable/set-approval
processes or application code calling `enablePrinter`). Every approval path
publishes the existing commit wakeup; the connection's serialized writer compares
the current state with the last frame it sent and resends only on a change, so an
unrelated job wakeup does not repeat it. An empty inventory yields
`{ "printers": [] }`. A printer removed in QQQ (`removed_at` set) is left out and
the frame is pushed again; it reappears, not approved, once the device shares the
queue again. The client does not acknowledge the frame; it carries no job
or document data. Connections without the capability never receive it.
+56
View File
@@ -0,0 +1,56 @@
# Installation and release operations
The stable server library is [QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2) (1.1.1 is the same code on QQQ RC.21 from Maven Central; 1.1.0 is superseded: its realtime upgrade fails behind an ingress that lowercases header names), paired with protocol 1.1.0 and Windows connector 1.2.1 (1.0.x/1.1.x connectors keep working on the unchanged 2.3 behaviour). It requires Java 21 and the QQQ 4.1.0-RC.22 dependency tuple, which is published only in the QRun-IO Gitea Maven registry. Its native printing extension is 2.4.0; the WebSocket subprotocol remains `qqq-printing.v1`. The previous stable library, [1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), remains available for rollback.
Gitea is primary for source, pull requests, CI, packages and server releases. This forge requires sign-in even for download links on public repositories. An HTML sign-in page is not an artifact. Use your existing approved forge identity; never commit its token or put it in a command line.
## Use the Maven registry
Configure the consuming application's existing Maven repository setup with repository ID `qrun-io` and URL `https://git.kof22.com/api/packages/QRun-IO/maven`. Enable releases and disable snapshots. Match that ID to a `<server>` entry in your private Maven `settings.xml`, using your existing managed username/token or encrypted Maven credentials. For example, environment references keep literal credentials out of the configuration:
```xml
<servers>
<server>
<id>qrun-io</id>
<username>${env.GITEA_USERNAME}</username>
<password>${env.GITEA_TOKEN}</password>
</server>
</servers>
```
Merge this entry into the existing settings; preserve other servers, mirrors and profiles. Supply the environment through your approved credential loader. The credential needs package read access. Add `com.kingsrook.qbits:qbit-printing:1.1.1` as shown in the [README](../README.md#add-to-a-qqq-application). QQQ 4.1.0-RC.22 and the printing library both resolve from this registry; third-party dependencies still come from the Maven Central mirror until the registry proxies them.
## Download and verify release files
Sign in to Gitea and download all three assets from the stable release: `qbit-printing-1.1.2.jar`, `qbit-printing-1.1.2.pom` and `SHA256SUMS`. In their download directory:
```powershell
Get-Content ./SHA256SUMS | ForEach-Object {
$hash, $name = $_ -split ' ', 2
if((Get-FileHash -LiteralPath $name -Algorithm SHA256).Hash -ine $hash) {
throw "Checksum mismatch: $name"
}
}
```
On Linux, use `sha256sum -c SHA256SUMS`; on macOS, use `shasum -a 256 -c SHA256SUMS`. Install the verified JAR/POM locally with the README's `install:install-file` command if your application does not consume the registry directly. The library needs its normal Maven runtime dependencies; copying the JAR alone does not deploy a complete host.
## Maintainer release path
Since 1.1.2 the repository builds on the shared `KofTwentyTwo/ci-workflows` Maven workflow (`.gitea/workflows/build.yaml`, pinned to `@v1`): every push runs `-Pui-demo verify`; a `vX.Y.Z` tag runs the same tests once and deploys the JAR/POM to the QRun-IO Gitea Maven registry under exactly that version. The former caller's pinned protocol checkout is gone; `PrintingContractTest` reads the vendored copy in `src/test/resources/protocol-fixtures` (kept byte-identical to the reviewed protocol commit named in its README), which is also what local `verify` uses. Earlier releases (1.0.0, 1.1.0, 1.1.1) went through the protected `qrun-release-v1` channel and the trusted publisher; their evidence below stands.
1. Update the POM version (the tag must equal it), `qqq.version` if the runtime moves, and active documentation on a branch in Gitea. Open a normal pull request; the push build must be green.
2. Merge normally and wait for the green main build (verify only; main never publishes).
3. Create `v<POM version>` at the qualified main commit without moving an existing tag and push it. The tag build tests and deploys `com.kingsrook.qbits:qbit-printing:<version>` to the registry; a tag build that fails publishes nothing and the next attempt is the next patch version (tags are immutable).
4. Read the registry JAR/POM back with the approved forge identity, create the Gitea release on the tag, attach the same JAR/POM and a flat `SHA256SUMS`, read them back, then publish. Never replace different bytes under a released version.
5. Record the source, tag build, release and verified hashes here. Publication is separate from installing a host or accepting physical printer output.
For 1.1.2, source `f5639a98be9a2f15d4c61cf0955fe4ecef85824f` (main; merge of PR 33: QQQ 4.1.0-RC.22 from the Gitea registry, shared ci-workflows pipeline), push build `6435` (`-Pui-demo verify`, 200 tests, 0 failures, 0 errors, 1 skipped, Checkstyle clean), tag build `6475` (tests again, deploy to the registry), release `v1.1.2`. Registry files and release assets match byte-for-byte on authenticated readback. The JAR SHA-256 is `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`; the POM SHA-256 is `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d`. RC.22 is not on Maven Central, so the green builds prove resolution from the registry.
For 1.1.1, source `2a561815fcec8b7c95e76bb4b36fba314f512027` (main; merge of PR 30, the realtime upgrade header-case fix), main run `5976` (verify, secrets scan, gate and post-publish succeeded), reactor artifact `4426`. The trusted publisher first refused the run because the 1.1.0 documentation merge had re-pinned the protocol fixture in `qrun-release.yaml`, changing the caller hash its enrollment verifies; the enrollment was re-reviewed in cluster-gitops (PRs 283 and 285) and the capture was then published unchanged. Registry files and release assets match the capture byte-for-byte. The JAR SHA-256 is `156720da6928f62d77101521a2953f6e1026d58d02e7fc7815a8c8fcbcedaefa`; the POM SHA-256 is `2fa451260a26f5b5ede30800d46c78050e223694d3d8dda640f9ca875d120ab0`.
For 1.1.0, source `eb2c16785d0723a47d4a22c68b8b04503108fc58` (main; merges of PR 25 printer approval and PR 28 printer/device removal), main run `5473` (verify, secrets scan, gate and post-publish all succeeded after the CI quota incident of the same afternoon), reactor artifact `4306`, release assets read back by the approved forge identity and matching the registry files byte-for-byte. The JAR SHA-256 is `3a51b7e7cf457cce5db16f138c4cc65b901730bda17ccaf1d3c714cf11d2bbf4`; the POM SHA-256 is `52e5a588e788bbe123dad574455493aea0a7df6cc473c78f15be09343048a5d4`.
For 1.0.0, source `2906c2bd5049924abd2114c8ef51a0ac9aac325e`, main run `3594`, reactor artifact `2741` and release `1356` were verified. Release assets and registry files matched byte-for-byte on authenticated readback. The JAR SHA-256 is `7ecb00e1f49272d6e885dc5f515bd4832aa52dd84e0613d9b6751f9e235a0961`; the POM SHA-256 is `8be7c30208a7fbb875dcebedb0ba6baf0e172aa0e7cf478a3d2863d1316ddf5f`.
GitHub remains a shadow/archive. GitHub Actions for this server repository and the protocol repository were retired on 2026-11-04. Their checked-in workflows and older release evidence are historical. The Windows repository retains its separate signing/update-feed workflow. Do not restart shadow workflows to publish a server release, claim GitHub attestations for 1.0.0, or imply the QBit was published to Maven Central.
+114
View File
@@ -0,0 +1,114 @@
# Retained print acceptance
Included in QQQ Printing 1.0.0, this optional Java integration preserves the existing native generation print
process and the existing printing queue. It adds no device wire fields or REST
routes. It is disabled until the host supplies
`PrintingQBitConfig.withRetainedSourceAuthority`. Current `printing_operator`
(or administrator) authority and native tenant/device/printer record checks
remain mandatory. Submission also requires the shared `printingSubmit` process
permission. This grants no device management or generic job-history access.
## Public contract
`RetainedPrintFacade.Request(requestId, sourceLocator, printerRecordId, title,
optionsJson)` contains no actor or asserted source binding. Request IDs use
`[A-Za-z0-9._:-]{1,128}`, including owner namespaces such as `echo:<scanId>`.
`submit` derives the signed-in native actor and returns an immutable
`Acceptance`; it includes the exact source, job/event identities, tenant,
initiating actor, destination, title and normalized options JSON, including any
`mediaProfile` ID/revision. `find` authorizes the same complete request and
returns that acceptance, null for observed absence, or a conflict for an
unresolved upload reservation. Reuse the same request identity after a lost
response. Never convert an unavailable lookup into absence or create another
intent automatically.
The host-configured `RetainedPrintSourceAuthority.resolve(locator, requestId)`
must authorize the current native actor, source association and currently
eligible immutable version. It returns `SourceReference(owner, opaqueReference,
sha256, sizeBytes, mediaType)`. `opaqueReference` is at most 4000 Java
characters, stable and canonical under that owner's rules; printing does not
parse owner-specific fields. The optional Record Files adapter must encode its
complete version/source/generation-request/initiating-actor/tenant tuple and
compare generation's committed receipt. Accepted MIME types are
`application/pdf` and `application/vnd.zebra-zpl`; the current configured
PDF/ZPL limits apply before the byte read, within the existing integer-length
storage ceiling. `read(reference, requestId)` must enforce the same current
permissions and return only those exact bytes. Hash/length verification remains
in printing's existing submission path. Do not supply arbitrary source
authorities per request or reconstruct a user session.
Source admission remains the owner's policy. An explicitly configured published
template may qualify generated output without a per-label release dialog;
external-upload quarantine is unchanged. Printing performs no automatic release
and creates no human-release audit. `resolve` must be read-only and must not
invoke a generation reconciliation operation that can generate or dispatch work.
Source/destination checks surround owner callbacks and precede acceptance.
Submission refuses an ambient QQQ transaction: queue acceptance is independently
committed and cannot be rolled back by a caller's transaction. Submission also
enforces the native job-table credential and channel mutation ceilings; lookup
uses the corresponding read ceilings without granting generic table access.
`find` calls no byte reader, submission or event writer, and its option
normalization cannot initialize settings. It reauthorizes the source and
destination records, then compares the exact accepted intent; a later exposure,
media inventory or dispatch-limit change does not reinterpret that historical
intent. A later permission loss may deny returning a receipt whose job already
committed; it never authorizes a replacement job.
## Persistence and replay
Existing `printing_job` gains nullable `retained_actor_id`,
`retained_binding_json`, and `accepted_event_id`. Existing `printing_event`
gains nullable `event_origin`. `initialize` applies additive upgrades; old
jobs/events remain unbound/unclassified. None of these internal fields are added
to the device `JOB_COLUMNS` DTO or native table metadata.
Submission and exact lookup both use the existing tenant admission lock, so an
in-flight inline acceptance cannot be mistaken for absence. The unique
`(tenant_id, idempotency_key)` serialize submissions. Retained keys use
`retained:` plus the request ID. Ordinary/bound collisions refuse. Complete
normalized intent includes actor, owner reference, content hash/length/media,
destination, title, options and tenant. The existing storage reservation/orphan
path is reused; it may commit an upload reservation before writing object
storage. The final job, retained binding, and server acceptance event commit
together. Failed acceptance-event insertion rolls back the job. Rollback or
orphan-cleanup failures are suppressed onto the original failure rather than
replacing it. Existing initialization widens legacy 300-character titles to
1024; the bound title preserves exact whitespace and Unicode and is compared on
replay. Acceptance-event JSON has `type=ACCEPTED`, `origin=SERVER_ACCEPTANCE`,
`status=QUEUED`; it does not claim device receipt or physical output.
This is atomic acceptance binding inside printing, not a distributed transaction
over the retained owner's authorization database. Deployment still owns durable
database/storage settings, bounded provider calls and current session policy.
## Committed observations
`observation(requestId, eventId)` reads only an exact committed event joined to
the accepted job. New ordinary device events are stamped `DEVICE_OBSERVATION`;
pre-upgrade unclassified events and server acceptance events cannot be returned
as device evidence. The current native operator must have current device/printer
row authority and satisfy the job credential/channel read ceiling. Before
returning any source metadata, printing calls the configured owner's
`authorizeObservation(source, requestId)` under the current reporter session,
then rechecks the current ceiling and destination authority. Its default
implementation refuses; adapters must explicitly implement their current
source-observation policy. This callback does not grant content access or
impersonate the initiating actor. This is an operator-authorized observation
lookup, not device enrollment or permission to forge reporter identity. The
optional RF authority adapter must preserve initiating actor separately from
current reporting actor and map only supported stored states. Neither `SPOOLED`
nor `SPOOLER_COMPLETED` proves physical output.
## Verification scope
The tests use real H2 transactions and native QQQ row authorization with
synthetic printers. They never contact a device or emit physical output.
Coverage includes commit-then-throw acknowledgment loss, concurrent exact
replay, held-admission-lock lookup, mutation ceilings, owner observation
refusal, callback revocation, exact content hash/length, configured document
bounds, title upgrade/replay, read-only historical lookup and original-error
preservation when rollback fails. The library is released; [release evidence](RELEASES.md) identifies its source
and artifacts. Configuring and qualifying a cross-QBit source authority remains
the integrating host's responsibility. Merely upgrading the library does not
enable an adapter, migrate stored source bindings or grant source access.
+186 -1
View File
@@ -1,4 +1,189 @@
# Session state — 2026-10-31
# Session state — 2026-11-06
## Current — 1.1.2 released (2026-11-06)
[QQQ Printing 1.1.2](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2) is published in the QRun-IO Gitea Maven registry (`com.kingsrook.qbits:qbit-printing:1.1.2`) and as a Gitea release. It is the 1.1.1 code built on QQQ **4.1.0-RC.22**, which exists only in that registry (James's direction: everything moves to Gitea, no more Maven Central), on the shared `ci-workflows` Maven pipeline (`build.yaml@v1`, `-Pui-demo`; the protected `qrun-release` caller and the trusted-publisher enrollment are retired, cluster-gitops PR 288). `qqq-frontend-next` 1.0.0-RC.37 was mirrored into the registry byte-for-byte from Central so the ui-demo profile resolves from Gitea too; only third-party libraries still come through the Central mirror. Source `f5639a98be9a2f15d4c61cf0955fe4ecef85824f` (PR 33), push build 6435 (200 tests, 0 failures), tag build 6475 (deploy); JAR `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`, POM `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d`. No printing contract change.
## Superseded — 1.1.1 released (2026-11-06)
[QQQ Printing 1.1.1](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.1) is published on primary Gitea and its Maven registry (`com.kingsrook.qbits:qbit-printing:1.1.1`). It supersedes 1.1.0: Forge's adoption qualification of 1.1.0 (`~/.cache/gg-forge-printing-cohort-20261106/HANDOFF.md`) reproduced a connection failure before WELCOME when the WebSocket upgrade headers arrive lowercase, because the socket callbacks read `X-QQQ-Device-Id` from the WebSocket context, whose header lookup is case-sensitive. 1.1.1 resolves the device once on the authorized HTTP upgrade and carries it as a connection attribute; `PrintingRealtimeTest.lowercaseUpgradeHeadersReachWelcomeAndFrames` fails on the old code and passes on the fix. No contract change: extension 2.4.0, wire v1, subprotocol unchanged. Source `2a561815fcec8b7c95e76bb4b36fba314f512027` (PR 30), main run `5976`, reactor artifact `4426`, release `1381`; JAR `156720da6928f62d77101521a2953f6e1026d58d02e7fc7815a8c8fcbcedaefa`, POM `2fa451260a26f5b5ede30800d46c78050e223694d3d8dda640f9ca875d120ab0`, read back against the capture and the registry. The publisher had refused the run until its enrollment hash was re-reviewed (cluster-gitops PRs 283 and 285) after the 1.1.0 docs merge re-pinned the protocol fixture in the release caller.
## Superseded — 1.1.0 released (2026-11-05)
[QQQ Printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0) is published on primary Gitea and its Maven registry: native printing extension **2.4.0** (`printer-approval-v1` capability and the `PRINTERS` frame) and the Remove printers / Remove device archives purged by retention. Source `eb2c16785d0723a47d4a22c68b8b04503108fc58`, main run `5473`, reactor artifact `4306`; JAR/POM hashes in [release operations](RELEASES.md). Pairs with [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0); the Windows connector 1.2.1 is published (https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1). The CI fixture pin now follows protocol 1.1.0 source `5005f3c9e27053b6c327bb923695012832c0fdcc`. Forge adoption is a separate, coordinated step; 1.0.0 stays available for rollback.
## Current — 1.0.0 released
[QQQ Printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) is published on primary Gitea and its Maven registry. Source `2906c2bd5049924abd2114c8ef51a0ac9aac325e` passed main run `3594`; reactor artifact `2741`, release `1356` and both registry files were verified against the captured bytes by authenticated readback. Gitea requires sign-in. See [release operations and hashes](RELEASES.md).
Windows connector 1.0.1 and protocol 1.0.0 are published. The server uses QQQ 4.1.0-RC.21, native printing 2.3.0, wire v1 and client-initiated WebSocket control with separate HTTPS documents. The release preparation's existing `-Pui-demo verify` reported 193 tests, zero failures/errors, one opt-in workload skip and zero Checkstyle violations; the normal hosted main checks subsequently passed.
GitHub is a shadow/archive; server and protocol Actions were retired on 2026-11-04. Windows signing/update-feed workflows remain separate. Current setup, operator, storage and release documentation now describe this released state. This documentation closeout changes no application code or workflows and performs no new hardware/provider qualification. No server 1.0 publication task remains open.
## Historical checkpoints
The entries below are dated development evidence, including superseded pending tasks and older versions. They are not the current release status; use the section above and [current tasks](TODO.md).
## Standards and small-label release cohort — 2026-11-01
The strict standards tree (commit `43253f6`) combines the 2.3.0 operator/media/retained source with the
Error Prone 2.51.0 / NullAway / `-Werror` compiler gate, null contracts, strict headers, the development
fixture style gates and the official-npm Pyright script gates. Observed locally on that tree: main and test
compile clean and the full `-Pui-demo verify` passed (189 reported, 0 failures, 0 errors, one intentional
opt-in 10,000-job skip, 0 Checkstyle violations). This qualifies that integrated source only.
The 4 x 1 and 4 x 2 inch label work (compact layouts, explicit PDF job paper in millimetres that never mixes
with a media profile, and the 4 x 6 path unchanged) is being merged onto it in the release-integration tree.
Its four conflicts (`PrintingProcesses`, `PrintingService`, `PrintingTestDocuments`, `TestPrintProfile`) were
resolved by keeping all label behavior and tests and layering the strict contracts: `jobOptions` is
`@Nullable` (null means the queue default applies), `validPaperMillimetres` accepts a missing value, redundant
return wrappers are removed, and no suppression or rule change was added. Submission, authority, retained
binding, replay and queue code paths were not edited.
**Local gate passed (observed):** on the merged cohort, main and test compile clean under the strict compiler and
the full `-Pui-demo verify` passed with 193 tests, 0 failures, 0 errors, one intentional opt-in 10,000-job skip and
0 Checkstyle violations. CI and the README pin the final protocol commit
`42c0d249f81395f00408ec0f92638dcbbf5deb03` (merged Gitea protocol PR 2); earlier pins elsewhere in this file are
historical. **Still pending, not observed:** hosted Gitea CI, publication, installation and any physical print. The
installed printer default is untouched; no printing was performed.
**Release version:** the project version is now the immutable prerelease `0.4.0-rc.1` (it was `0.4.0-SNAPSHOT`, which the
tag workflow cannot release). The release tag must be exactly `v0.4.0-rc.1`. The QQQ `4.1.0-RC.20` dependency tuple, the
parent and all code are unchanged. Maven Central publication and deployment remain separate operations.
## Echo operator/media and retained-source candidate — 2026-11-01
The isolated Echo candidate adds native printing extension **2.3.0** (after the protected-main 2.2.0 rendering extension) on the
unchanged product `0.4.0-SNAPSHOT` and QQQ `4.1.0-RC.20` dependency tuple.
`printing_operator` permits the authorized printer/media catalog and exact-actor
submission/reconciliation without device administration or general job history.
Reported Windows queue-default media is revision-bound; clients without the current
`printer-media-v1` capability cannot claim jobs carrying a media selection.
The opt-in retained-source facade shares that boundary and commits immutable
source/actor/destination/intent plus its server acceptance event inside the existing
queue transaction. Its owner adapter is required and default-deny; read-only lookup
never generates content or submits a replacement job. See
[operator catalog](OPERATOR-CATALOG.md) and
[retained acceptance](RETAINED-PRINT-FACADE.md) for exact contracts and limits.
Protected main advanced to a rendering release at printing 2.2.0 (header/footer
chrome, content sections, render-to-PDF) while this candidate was qualified, so the
operator/media/retained feature was merged onto it and moved to 2.3.0. The earlier
183-test gate is historical evidence of the pre-merge source only.
Observed after the merge, locally: the OpenAPI contract was regenerated with the
existing explicit update switch and its 1.0.0 compatibility baseline hash was
unchanged. A sandbox full verify reported 188 tests with 11 loopback setup errors and
no assertion failures (environmental). The first actual-user full verify reported 188
tests, no assertion failures and **one error**:
`PrintingRealtimeTest.mediaCapabilityIsReplacedOnEveryAuthenticatedHello` failed
only when JUnit removed its temporary directory. Cause: `PrintingRealtimeRoutes.close()`
shut its executor down without waiting, so a channel drain still running after the
host stopped queried a deleted file database and recreated it empty. The fix waits
(bounded) for running drains, with regression
`closeWaitsForChannelWorkersAlreadyRunning`. After it, five consecutive focused
`PrintingRealtimeTest` runs passed (4 tests each, no empty-database warnings) and the
full `-Pui-demo verify` passed: **189 reported, 188 executed, one intentional opt-in
workload skip, zero failures/errors, zero Checkstyle violations**; the source-header
guard over 146 tracked files and staged whitespace checks passed. The earlier failing
logs are preserved. This is local evidence only: hosted CI on the signed candidate
head, publication and cross-QBit consumer qualification are pending. The suite runs
against synthetic H2 fixtures; no live database, connector, printer or shared
artifact has been changed. The
experimental API 1.10 used by Echo is a separate owner-controlled tuple and has not
been substituted for this candidate's qualified RC.20 dependencies. Publication,
cross-QBit consumer qualification and any physical output remain separate gates.
Native destination and storage callbacks cannot carry stale actor, session,
credential, channel or row authority into admission, replay or commit. Receipt
lookup authorizes the original stored printer-row ID; a replacement row with the
same device/queue pair cannot inherit earlier acceptance history. The regressions
reproduce both prior paths, verify denial and rollback, and preserve authorized
read-only observation after the original queue loses exposure. Media IDs are
bounded to the shared 128-character contract; long device display names remain
supported without truncation.
## Local Docker development — 2026-11-01
The optional native-auth probe now shares production Core discovery and the
default workload's printing-major-2 gate, accepting stable 2.1.0 while retaining
the exact experimental API 1.9/native grant requirement. Its previous exact-2.0.0
check reproduced a rejection before the fix. Native and default client image
builds pass 12 and nine fake-HTTP compatibility checks respectively, with zero
warnings/errors. One fresh provider run against server source `3592dec`
(printing API 2.1.0) and the reviewed probe correction passed all eight checks.
All source hashes remained unchanged through completion; independent cleanup
found zero owned containers, volumes and networks. No installed runtime,
physical printer or host trust setting changed.
The developer tooling is published on `main` through [PR #21](https://github.com/QRun-IO/qbit-printing/pull/21),
merged as `3592decba8087439aff5d1b75a9726c72f460cb9`; its tree matches tested
head `def8d961f3d82a1235f8e45ffbeacb0fbe79c421`. The final hosted
[development run](https://github.com/QRun-IO/qbit-printing/actions/runs/41324370247)
passed the actual 40-job HTTP/WS smoke and 65 S3 checks, nine pinned-Core discovery
compatibility cases, and all 33 script tests on each of Windows and Ubuntu.
The [Java run](https://github.com/QRun-IO/qbit-printing/actions/runs/41324370253)
reported 133 tests: 132 executed, one intentionally skipped opt-in workload test,
zero failures/errors, and zero Checkstyle violations. That workload separately
passed on the combined source. These are development-tooling publication results,
not a server release or live runtime upgrade. The experimental API 1.9 candidate
remains opt-in and is not adopted into the released application dependencies.
The current qualification scope is local Docker only. The new `dev/Invoke-Dev.ps1`
provides owned Start/Test/Stop commands for a native QQQ TABLE host, dedicated
MinIO storage and optional real Authentik/PostgreSQL. Source images and the client
Core archive are pinned. Generated credentials stay in private state outside the
checkout; no installed connector, live database or physical printer is used.
Portable Docker checks passed: the default 40-job smoke, 65 actual QQQ S3 backend
checks, and an opt-in 10,000-job/two-tenant/eight-client run with 30,000 durable
server receipts and 400 submission plus 400 receipt replays. The burst completed
in 147.856 seconds (about 67.63 jobs/second); its enqueue-to-ACK p95 was 32.984
seconds while work intentionally queued. This is HTTP/WS with a fake file printer
and a simple fixture client journal, not TLS, hardware or a long soak claim.
Native TABLE login, identity, frontend asset and logout passed. Real Authentik
configuration/discovery passed; a registered redirect continued to login and the
otherwise-identical unregistered redirect was rejected. The released RC.20 host
still advertises API 1.8 without the native session grant: browser login, renewal
and the Windows callback are not established by these checks. All owned test
containers, volumes and networks were removed. Lifecycle/bootstrap regressions
passed 28 cases including experimental-profile refusal and diagnostic-redaction
checks; scripts passed parser and PSScriptAnalyzer checks.
The separate opt-in native-auth profile passed eight actual checks against the
immutable upstream API 1.9 candidate `ecf0f550a0357cec6387c2b54efca128a405a629`:
ordinary TLS refusal before container-only CA installation, typed-grant discovery,
real Authentik login through the production Core PKCE client, wrong-Origin cookie
rejection, denied-user permissions, logout replay refusal, server-side renewal
after access-token expiry and denial after provider refresh-token revocation.
The final run exited zero and all owned containers, volumes and networks were
removed. Earlier failed fixture attempts remain in private evidence; their fixes
were standard-flow readiness/binding, bounded provider redirects and the correct
Authentik CSRF header. No authentication check was disabled.
This experimental candidate is not the default released RC.20 runtime or a
published production dependency upgrade. Container HTTPS and automated real
provider flows do not qualify the Windows browser callback UI, host trust,
months-long operation or physical printing. See [local development](../dev/README.md),
[provider limits](../dev/AUTHENTIK.md) and [native qualification](../dev/native-auth/README.md).
Earlier release checkpoints below are historical. This developer-tooling merge
did not publish a new server product release or change the live paired runtime.
## Opt-in service workload regression — 2026-11-01
The combined test/development change also includes a 10,000-job, two-tenant,
eight-lane file-backed service regression with replay, exact accounting,
pagination and cold reopen checks. It is opt-in and never accesses a printer.
The original isolated full gate passed 119 tests with zero Checkstyle violations;
the final exact receipt-count assertion passed its focused rerun. Its source is
unchanged when integrated here, and the combined checkout passed one focused
workload test with zero Checkstyle violations. The earlier released-artifact
network run and
40-job idle latency measurements are recorded in
[qualification evidence](QUALIFICATION-2026-11-01.md), separately from the portable
Docker run above. No runtime source, dependencies or version changed.
## Active device controls and standard QQQ authentication
+17
View File
@@ -0,0 +1,17 @@
# Owned source notice check
Run these commands from the repository root with the existing Python 3 interpreter:
```text
python3 .github/scripts/test-source-headers.py
python3 .github/scripts/check-source-headers.py
```
The standard-library checker enforces the scope of the reviewed 2026-11-01 header pass, not an estate-wide licensing or formatting audit. `.github/source-headers.json` names the owned source/config path families, comment syntax, accepted holder/license pairs and explicit exclusion reasons. Python `fnmatch` patterns match slash-containing relative Git paths; new files in those families are covered after they are tracked. Empty selections fail. The checker does not scan untracked build output or modify files.
Only leading language comments count. Copyright text in a string, Python docstring or comment after code is rejected. UTF-8 BOMs, initial shebangs, PowerShell `#Requires` directives and XML declarations are handled before the notice. These declarations cannot be displaced beneath a new notice. PowerShell block comments are recognized only for PowerShell files. Existing years and holders are not rewritten. Complete canonical Apache notices are valid without redundant SPDX; Java still requires the full Apache block. A contradictory SPDX identifier fails.
JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. The former GitHub jobs invoked them using the runner's available Python; the scripts remain usable locally without a new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. Run these checks locally when modifying owned notices; the primary shared CI channel owns its current check selection. The old `.github` workflow invocation is retained as history.
+8 -6
View File
@@ -1,6 +1,6 @@
# Print document storage and retention
Status: audit implementation pending final verification and release. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings.
Included in the released QQQ Printing 1.0.0 library. QQQ 4.1+ hosts provide the existing storage and scheduler metadata; this module does not create buckets or copy credentials into printing settings.
## Deployment boundary
@@ -10,15 +10,15 @@ Keep access keys, role credentials and endpoint configuration in the host's esta
The code validates the selected S3 metadata, detects conflicting bucket bindings within the QQQ instance and uses tenant-specific object namespaces. Each job retains its storage table, destination identity and exact document reference. Retrieval and cleanup validate those values and fail closed if metadata is retargeted or the reference belongs to another tenant/job. Cleanup deletes exact eligible objects; it never lists and wipes a prefix. Tenant prefixes are an application isolation control, not a substitute for separately scoped IAM roles or separate customer buckets.
Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. No live bucket has been created or changed by the audit.
Application metadata cannot prove that a bucket is unused by another application or that deployed IAM/bucket policies are correct. Deployment qualification must check those external controls, public-access blocking, transport/encryption policy and the intended customer/environment boundary. The QBit does not create or change bucket policies.
## S3 key layout and migration
QQQ filesystem 4.1.0-RC.20 builds S3 keys with the host filesystem separator. With empty base paths, Windows writes two leading backslashes before the logical `printing/<tenant-hash>/<job>.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms.
The reviewed `qqq-s3-filesystem-v1` layout uses the host filesystem separator; the original platform comparison was recorded against QQQ 4.1.0-RC.20. With empty base paths, Windows writes two leading backslashes before the logical `printing/<tenant-hash>/<job>.ext` reference; Linux writes the canonical reference. Same-platform object operations have been qualified in isolated S3-compatible storage. The logical reference alone is therefore insufficient for IAM prefix rules or migration between platforms.
Printing fingerprints the reviewed `qqq-s3-filesystem-v1` layout and the actual `File.separatorChar` code point (47 or 92), alongside the endpoint, bucket, region, path-style setting and backend/table base paths. Ordered JSON encoding preserves the identity component boundaries, including valid multiline base paths. A different layout rejects historical reads, new retries and cleanup with a destination-mismatch error before accessing those objects. New retries whose source binding is already incompatible fail before reserving another upload; the document loader also rechecks the binding when reading. An already-created retry still returns its idempotent receipt. Cleanup preserves content availability, retained-byte accounting and orphan reservations on mismatch.
This S3 feature is unreleased. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration.
The released storage implementation requires this layout marker. Old experimental fingerprints without the layout marker are deliberately rejected, even on their original platform. Startup never rewrites those bindings or moves/deletes their objects. Preserve the original database and objects until an explicitly reviewed migration or disposal; do not replace hashes merely to bypass the guard. Inline database documents remain readable and can be explicitly retried into the configured destination without any automatic storage migration.
This guard does not make QQQ keys portable. The upstream owner must provide portable key construction and legacy-key handling. Adopting that change, including a change on the same operating system, requires an explicit layout-marker review and a migration plan for historical bindings and exact objects. The marker is a reviewed compatibility contract, not automatic detection of dependency implementation changes. Cross-platform migration and production IAM remain separate qualification work.
@@ -26,9 +26,9 @@ This guard does not make QQQ keys portable. The upstream owner must provide port
Record Files owns original versions and attachments; document generation owns rendering. Printing owns a separate delivery payload and queue receipts. Never bind printing cleanup to a Record Files bucket or pass an original's storage key as a printing-owned reference. Printing expiration must not remove a source attachment or weaken its retention policy.
The current independent integration seam accepts bytes and a stable business idempotency key. An authorized immutable-version reader and source/job correlation contract are being coordinated with Record Files; that API is not yet implemented or assumed here. Printing currently checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. A shared adapter must define that boundary before promising revocation propagation.
Direct submission accepts bytes and a stable business idempotency key. The optional [retained-source facade](RETAINED-PRINT-FACADE.md) additionally binds an authorized immutable source, actor, destination and acceptance receipt. It is disabled until the host supplies its source authority; this repository does not configure a Record Files adapter automatically. Printing checks device/destination authorization at dispatch, not later revocation of the source attachment's permissions. The integrating owner must define that boundary before promising revocation propagation.
The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. The future retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy.
The existing `PrintEnqueueEffectTarget` accepts base64 document bytes in the host's durable outbox. That outbox may retain another delivery copy outside this module's S3 cleanup policy. Hosts must manage its retention separately; do not claim that configuring printing S3 moves existing outbox payloads or all upstream copies into the printing bucket. A host-provided retained-version adapter should carry authorized opaque source IDs/hash/length/media type and actor/correlation, not bucket URLs or duplicated document bytes. Direct byte submission stores the printing-owned payload through the configured storage backend without adding this host-outbox copy.
## Administration and scheduled cleanup
@@ -36,6 +36,8 @@ Printing administration exposes retention settings and a reviewed **Run cleanup*
When the host supplies a scheduler, maintenance runs every five minutes in bounded batches of at most 200. That permits a nominal 57,600 eligible documents per day before storage latency or failures; it is not measured production throughput. Changing a retention setting does not itself purge documents. Disabling cleanup preserves content. Without a configured scheduler there is no automatic job; the administration screen must show that condition.
**Delete printers** and **Delete print stations** delete the record at once and keep every job (see the [walkthrough](UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer)); a deleted station's retained-byte counter stays until cleanup has removed its last stored document, then cleanup deletes the counter. The same run purges archived inventory. **Remove printers** and **Remove print station** never delete rows; they set `removed_at` (shown as **Removed at**) so the rows leave the default views, selectors, catalog, claims and the realtime `PRINTERS` frame while job history keeps resolving to them. After the document step, each cleanup run hard-deletes, within the remaining batch allowance, removed printers that no `printing_job` references (neither by `printer_row_id` nor by device and queue identity) and then removed devices that have no jobs and no printer rows left; a removed device's control receipts and resource accounting go with it. Anything a job references is never deleted, so a removed printer or device stays, hidden, for as long as its jobs exist. The purge counts toward the run's reported total and is logged with the printer and device counts; disabling cleanup pauses it together with document removal. Cleanup never deletes an active (non-removed) printer or device, and never deletes a job.
Only eligible successful terminal jobs lose their document content. Active, failed or uncertain work remains protected. Job metadata, document hashes, receipts and idempotency records remain available for auditing and duplicate prevention. Legacy completion times are recovered only from durable successful events; unknown completion dates remain protected. Existing inline database documents remain readable; the initial storage integration does not silently migrate old bytes to S3. Reprinting a job whose document has been removed returns an explicit unavailable-document result.
## Failure and versioning behavior
+88 -1
View File
@@ -1,5 +1,90 @@
# Follow-up
## Current — release closeout (2026-11-04)
- [x] Adopt published QQQ 4.1.0-RC.21 and complete the existing local verification.
- [x] Pass normal primary CI, publish qbit-printing 1.0.0 and verify the release/registry bytes.
- [x] Publish protocol 1.0.0 and Windows connector 1.0.1; keep their distinct API/product versions documented.
- [x] Update active setup, operating, security and release documentation for Gitea and the released features.
No server 1.0 release task remains open. Deployment-specific database, IAM/provider policy and optional cross-QBit adapters remain the integrating application's responsibilities, described in the operating guides. The legacy optional Authentik diagnostic is documented as an RC.20-era fixture; updating it is not part of this release closeout.
## Historical checklists
Everything below records earlier development checkpoints. Unchecked historical items do not reopen completed or excluded release work.
## Echo integration candidate
- [x] Implement authorized operator catalog, native submission/process permission,
exact actor/request lookup and reported driver-default media revisions.
- [x] Integrate the opt-in atomic retained-source facade using the same queue,
current authority, immutable acceptance and exact device-event observation.
- [x] Close native callback authority changes and preserve the exact accepted
printer-row identity during read-only request reconciliation.
- [x] Complete local combined verification on the merged 2.3.0 candidate: 189
reported, 188 executed, one opt-in workload skip, zero failures/errors and
Checkstyle violations (the earlier 183-test gate was pre-merge). Preserve exact
source, draft protocol fixtures and frozen runtime hashes.
- [ ] Pass hosted CI on the final signed candidate head; local results do not
replace it.
- [ ] Qualify Echo/Generation's exact core tuple and configured source authority;
do not silently adopt experimental core or bypass external-upload admission.
- [ ] Publish the reviewed additive contract and coordinate client/runtime adoption
before owner-operated end-to-end physical acceptance.
## Script quality gates
- [x] Pin Ruff and strict Pyright (official npm package) with locked restores; reject Python outside `.github/scripts/` and zero-file Pyright runs; negative tool probes pass locally.
- [ ] Observe the new `verify` job steps pass on hosted CI before relying on them.
## Integrated compiler, standards and fixture gates
- [x] Layer the reviewed Error Prone/NullAway compiler gate, null contracts and
style corrections over the merged 2.3.0 source without relaxing any security check.
- [x] Integrated source compiles clean under Error Prone 2.51.0/NullAway/-Werror for
main and test sources (R27), and the full local verify passed: 189 reported, 0
failures, 0 errors, one intentional 10k-workload opt-in skip, zero Checkstyle
violations (R28). This qualifies the integrated source only; the earlier
133-test/204-jar results describe the old baseline.
- [ ] Observe the `development-clients` and `development-servers` jobs, the compiler
guard and the script-quality gates pass on hosted CI.
## Small-label release cohort
- [x] Merge the 4 x 1 / 4 x 2 label profiles, compact PDF/ZPL layouts, explicit PDF job paper and their tests
onto the strict standards tree, keeping the strict headers, null contracts and Error Prone fixes.
- [x] Cohort gate passed locally on the merged tree: strict main/test compile and full `-Pui-demo verify`, 193 tests,
0 failures, 0 errors, one intentional opt-in skip, 0 Checkstyle violations.
- [x] CI and the README pin the final protocol commit `42c0d249f81395f00408ec0f92638dcbbf5deb03` (merged Gitea PR 2).
- [ ] Observe hosted Gitea CI, then publish and install; physical confirmation of the short labels stays an owner step (4 x 6 stock is loaded).
## Current local development qualification
- [x] Reuse production discovery and the shared major-2 gate in the optional
native-auth probe; reproduce the old 2.1.0 rejection, then pass 12 native and
nine default workload fake-HTTP compatibility checks without provider launch.
- [x] Rerun the eight-check native provider profile once against printing API 2.1.0
after review; all eight passed with unchanged source hashes and owned cleanup.
- [x] Supply portable, owned Docker Start/Test/Stop commands with private synthetic
credentials and explicit cleanup; leave the installed pairing untouched.
- [x] Run the 40-job smoke, 65 real S3 backend checks and 10,000 network jobs with
30,000 durable server receipts and replay/isolation assertions.
- [x] Qualify real Authentik provider discovery and registered/unregistered redirect
controls while reporting the released QQQ 1.8 native-grant absence honestly.
- [x] Qualify the separate experimental API 1.9/container-TLS native login,
denied-user/wrong-Origin permissions, logout, short renewal and provider revocation.
- [x] Include the opt-in 10,000-job pooled-SQL/cold-reopen service regression and
preserve separate released-artifact network/idle-latency evidence.
- [x] Publish developer tooling through PR #21, merged as
`3592decba8087439aff5d1b75a9726c72f460cb9`: hosted 40-job/65-S3 checks,
33 script tests on both Windows and Ubuntu, and the ordinary Java gate
(133 reported, 132 executed, one opt-in skip, zero failures/Checkstyle violations).
Keep experimental API 1.9 artifacts opt-in; this merge does not adopt them into
the released application or upgrade the live paired runtime.
The older release checklists below are historical context. AWS/cloud-account
provisioning is outside the user's current local-Docker-only request.
- [x] Complete native device controls, per-record/selection actions, queue/history, dashboard and legacy-boundary integration tests; 110 tests pass with zero Checkstyle violations.
- [x] Complete 25 actual Windows Core client/native QQQ interoperability checks with table-based authentication, no printers and no jobs.
- [ ] Complete installed candidate acceptance after protected CI.
@@ -46,7 +131,9 @@
- [x] Implement the dedicated QQQ object-storage document backend; live S3/IAM qualification remains pending configuration.
- [ ] Logical destination mappings.
- [x] Run the coordinated Java gate for profile migration, long text and 10,000-row bounded-history regressions; included in the passing 28-test v0.1.3-rc.1 gate.
- [ ] Qualify sustained throughput separately from synthetic bounded-history tests.
- [ ] Qualify long-duration soak and sustained physical throughput separately
from the completed bounded network/fake-printer workloads; see
[qualification evidence](QUALIFICATION-2026-11-01.md).
- [x] Device-scoped test/retry/cancel/queued-job routes and native record/table cancellation/reprint review, explicit duplicate-risk consent and retry idempotency.
- [x] Implement admin document retention and bounded scheduled cleanup, default 365 days.
- [ ] Server-side job expiry and operational alerts.
+25 -4
View File
@@ -10,15 +10,36 @@ Open **Printing** in the navigation. The home presents the current tenant and re
## Enroll a facility computer
Choose **Enroll a print device** from Printing home or the Print devices table. Use the resulting short-lived enrollment code in the Windows companion's Connection settings. Once enrolled, select the installed printer queues to expose in the companion. Exposure is opt-in and persists in the service.
Choose **Enroll a print station** from Printing home or the Print stations table. A print station is the computer that runs the print client. Use the resulting short-lived enrollment code in the Windows companion's Connection settings. Once enrolled, select the installed printer queues to expose in the companion. Exposure is opt-in and persists in the service.
Open **Printers** in QQQ. The operational columns show the printer name, named print device, status, approval, local exposure, and last contact. Approval and exposure are independent: a printer must be both approved in QQQ and exposed on the computer before new jobs can be dispatched.
Open **Printers** in QQQ. The operational columns show the printer name, named print station, status, approval, local exposure, and last contact. Approval and exposure are independent: a printer must be both approved in QQQ and exposed on the computer before new jobs can be dispatched.
![Filtered printer table](screenshots/printers.png)
## Act on one printer or a selection
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Remove printers**, **Delete printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
**Remove printers** archives the selection: each printer gets a **Removed at** time, loses approval and exposure, and disappears from the operator catalog, the connector's approval frame and the device/printer choices in the setup and test-print forms. Its record and job history stay. The Printers table has no server-side default filter, so add **Removed at is empty** to the Printers view you keep (the screenshots above predate this field) to hide archived rows; clear it to audit them. If the Windows companion shares the same queue again, the next inventory brings the printer back as exposed but not approved, so approve it again before it can print.
## Remove a print station
Open a Print stations record or select rows and choose **Remove print station**. The action requires the confirmation checkbox. It revokes the station's access permanently through the same control as **Revoke print station access**, then marks the station and all of its printers removed. The station drops out of the Print stations view once you add **Removed at is empty** there, the setup and test-print choices and every enrollment, claim and control path; its jobs remain in Print jobs and its control history keeps the REVOKE receipt. A removed station cannot be un-removed; enroll the computer again to create a new station. Retention cleanup eventually hard-deletes removed stations and printers that no job references. To take the record out of the list now, delete it.
## Delete a print station or a printer
Deleting needs the `printing_delete` permission in addition to `printing_admin`; without it the two actions are not offered.
Open a Print stations record, or select rows in Print stations, and choose **Delete print stations**. Tick the confirmation and continue. The review screen then says exactly what will happen for the selection: how many stations are deleted, how many printers go with them, how many queued jobs are canceled, how many jobs already sent to a station are closed as outcome unknown, and how many finished jobs keep their history. It also lists, by name, every record that will not be deleted and why. Nothing has changed yet. Submit to delete.
- The station's access is revoked first. The computer cannot connect as that station again; enroll it again to print, and it is then a new station.
- Its printers are deleted with it.
- Queued jobs become **Failed** with the message "Canceled before dispatch: its print station was deleted". Jobs that were already sent to the station and had not reported a result become **Outcome unknown**: check the printer before printing them again.
- Finished jobs stay in Print jobs. Their **Print station**, **Computer name**, **Printer** and **Windows queue name** columns show the names they were printed under.
- A station that is connected (status Online or Paused) and printing is refused. Wait for the jobs to finish, or use **Disconnect print stations** or **Revoke print station access** first.
- **Deletion history** in the Printing app records who deleted what and when.
**Delete printers** works the same way on Printers. The printer's station stays in service. If the station still shares that Windows queue, its next inventory reports it again and it comes back as a new printer that is exposed and not approved; stop sharing it in the print client on that computer to keep it away.
![Actions on the selected printer](screenshots/printer-actions.png)
@@ -38,4 +59,4 @@ The Windows companion shows recent jobs and a persistent status footer. **View s
## Preview acceptance and limits
The live preview was inspected with frontend 1.0.0-RC.37: the six operational printer columns, named device relationship, disabled generic write actions, and dashboard-to-filtered-table navigation were observed. Selecting the synthetic Zebra, opening Print a test label, reviewing and submitting completed with one printer processed. No physical printer was connected to this demo. Native process authorization and multi-printer execution are also covered by Java tests. A broad browser/accessibility run and final elevated companion interaction run remain separate acceptance tasks; see the README and the Windows repository's UX review.
The live preview was inspected with frontend 1.0.0-RC.37: the six operational printer columns, named device relationship, disabled generic write actions, and dashboard-to-filtered-table navigation were observed. Selecting the synthetic Zebra, opening Print a test label, reviewing and submitting completed with one printer processed. No physical printer was connected to this demo. Native process authorization and multi-printer execution are also covered by Java tests. These are historical preview observations, not evidence for every browser size or deployed printer driver. Current release status is in the [README](../README.md) and [release guide](RELEASES.md).
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@qrun-io/qbit-printing-script-quality",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qrun-io/qbit-printing-script-quality",
"version": "0.0.0",
"license": "Apache-2.0",
"devDependencies": {
"pyright": "1.1.414"
},
"engines": {
"node": ">=24"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pyright": {
"version": "1.1.414",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.414.tgz",
"integrity": "sha512-FPZZb51jepDX4eP7TEYDeNFtmE3WgwkkEcJpvH3/QmUSsj0EAy3LXu+xB4T/FWDejtsXlCfFr/rbWFjwuwuXww==",
"dev": true,
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@qrun-io/qbit-printing-script-quality",
"version": "0.0.0",
"private": true,
"description": "Development-only script quality tooling for QBit Printing; no runtime dependency.",
"license": "Apache-2.0",
"engines": {
"node": ">=24"
},
"devDependencies": {
"pyright": "1.1.414"
},
"packageManager": "npm@11.19.1"
}
+61 -6
View File
@@ -1,16 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 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>
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.2.1</version><relativePath/></parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>0.3.0-rc.1</version>
<version>1.1.2</version>
<name>QQQ Printing</name>
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
<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.25</qqq.version>
<maven.compiler.proc>none</maven.compiler.proc>
</properties>
<dependencyManagement><dependencies>
@@ -20,6 +25,9 @@
<dependency><groupId>org.junit</groupId><artifactId>junit-bom</artifactId><version>6.0.1</version><type>pom</type><scope>import</scope></dependency>
</dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>com.uber.nullaway</groupId><artifactId>nullaway-annotations</artifactId><version>0.14.3</version><scope>provided</scope></dependency>
<!-- Direct nullable contracts reuse the already-resolved runtime annotation version. -->
<dependency><groupId>org.jspecify</groupId><artifactId>jspecify</artifactId><version>1.0.0</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId><version>${qqq.version}</version></dependency>
@@ -29,7 +37,7 @@
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.1</version><scope>test</scope></dependency>
</dependencies>
<profiles><profile><id>ui-demo</id><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.37</version><scope>runtime</scope></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.38</version><scope>runtime</scope></dependency>
</dependencies></profile></profiles>
<build>
<resources>
@@ -44,10 +52,57 @@
<plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.3.1</version>
<dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>10.12.6</version></dependency></dependencies>
<configuration><configLocation>checkstyle/config.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><consoleOutput>true</consoleOutput><violationSeverity>warning</violationSeverity><failOnViolation>true</failOnViolation></configuration>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<!-- Development fixtures use distinct runtime tuples, but share the source style gate. -->
<sourceDirectories>
<sourceDirectory>${project.build.sourceDirectory}</sourceDirectory>
<sourceDirectory>${project.basedir}/dev/java</sourceDirectory>
<sourceDirectory>${project.basedir}/dev/native-auth/java</sourceDirectory>
</sourceDirectories>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
<consoleOutput>true</consoleOutput>
<violationSeverity>warning</violationSeverity>
<failOnViolation>true</failOnViolation>
</configuration>
<executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions>
</plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>21</release>
<proc>none</proc>
<fork>true</fork>
<executable>${java.home}/bin/javac</executable>
<showWarnings>true</showWarnings>
<failOnWarning>true</failOnWarning>
<compilerArgs>
<arg>-Werror</arg>
<arg>-Xlint:deprecation</arg>
<arg>-Xlint:unchecked</arg>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.kingsrook.qbits.printing</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
</compilerArgs>
<annotationProcessorPaths>
<path><groupId>com.google.errorprone</groupId><artifactId>error_prone_core</artifactId><version>2.51.0</version></path>
<path><groupId>com.uber.nullaway</groupId><artifactId>nullaway</artifactId><version>0.14.3</version></path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version><configuration><archive><manifestEntries><Implementation-Version>${project.version}</Implementation-Version></manifestEntries></archive></configuration></plugin>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version><configuration><mainClass>com.kingsrook.qbits.printing.PrintingDemo</mainClass></configuration></plugin>
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
[project]
name = "qbit-printing-script-quality"
version = "0.0.0"
requires-python = ">=3.13"
[dependency-groups]
dev = ["ruff==0.17.3"]
[tool.uv]
package = false
+56
View File
@@ -0,0 +1,56 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Based on K22 fbfc8d2cf3b5e16ae9750b04cf9de1794f1e7152.
# KofTwentyTwo Python lint + format configuration (K22-CODE-PY). Copy to the repo root
# (or merge into [tool.ruff] in pyproject.toml). `ruff format --check` and `ruff check`
# run in CI and pre-commit with zero findings allowed.
target-version = "py313"
include = [".github/scripts/*.py"]
extend-exclude = [".venv", "node_modules", "dist", "target", "bin", "obj"]
# Kingsrook: no practical line-length limit (ruff's maximum is 320).
line-length = 320
# Kingsrook: 3-space indentation. Ruff's formatter supports it fully; pycodestyle's
# "multiple of four" rules (E111/E114) are therefore not selected.
indent-width = 3
[format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
docstring-code-format = true
[lint]
select = [
"F", # pyflakes
"E4", "E7", "E9", "W", # pycodestyle errors that are layout-independent
"I", # isort: no wildcard imports, ordered
"N", # pep8-naming
"UP", # pyupgrade
"B", # bugbear
"S", # bandit security
"BLE", # blind except
"T20", # print statements: use logging (Kingsrook logging rule)
"ERA", # commented-out code (Kingsrook: no zombie code)
"FIX", # FIXME/TODO must be resolved or tracked
"TD", # TODO format: author and issue link
"PTH", # pathlib over os.path
"RET", "SIM", "C4", "PIE", "PL", "RUF",
"ANN", # type annotations on every function
"D", # docstrings on modules, classes, functions
"LOG", "G", # logging hygiene
]
ignore = [
"D203", # conflicts with D211
"D213", # conflicts with D212
"TD003", # issue link is enforced by review, not regex
]
[lint.per-file-ignores]
".github/scripts/check-*.py" = ["T201"] # CLI diagnostics are the public stdout contract, not application logging.
".github/scripts/test-*.py" = ["S101", "PLR2004"] # Synthetic assertions and explicit boundary values.
[lint.pydocstyle]
convention = "pep257"
[lint.flake8-tidy-imports]
ban-relative-imports = "all"
@@ -27,6 +27,7 @@ import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.logging.Logger;
import org.jspecify.annotations.Nullable;
/*******************************************************************************
@@ -37,7 +38,7 @@ public record DriverDataSource(String url, String user, String password) impleme
@Override
public Connection getConnection() throws SQLException
{
return (DriverManager.getConnection(url, user, password));
return DriverManager.getConnection(url, user, password);
}
@@ -45,15 +46,15 @@ public record DriverDataSource(String url, String user, String password) impleme
@Override
public Connection getConnection(String username, String secret) throws SQLException
{
return (DriverManager.getConnection(url, username, secret));
return DriverManager.getConnection(url, username, secret);
}
@Override
public PrintWriter getLogWriter()
public @Nullable PrintWriter getLogWriter()
{
return (null);
return null;
}
@@ -77,7 +78,7 @@ public record DriverDataSource(String url, String user, String password) impleme
@Override
public int getLoginTimeout()
{
return (DriverManager.getLoginTimeout());
return DriverManager.getLoginTimeout();
}
@@ -85,7 +86,7 @@ public record DriverDataSource(String url, String user, String password) impleme
@Override
public Logger getParentLogger()
{
return (Logger.getLogger("com.kingsrook.qbits.printing"));
return Logger.getLogger("com.kingsrook.qbits.printing");
}
@@ -95,7 +96,7 @@ public record DriverDataSource(String url, String user, String password) impleme
{
if(type.isInstance(this))
{
return (type.cast(this));
return type.cast(this);
}
throw new SQLException("Not a wrapper");
}
@@ -105,7 +106,7 @@ public record DriverDataSource(String url, String user, String password) impleme
@Override
public boolean isWrapperFor(Class<?> type)
{
return (type.isInstance(this));
return type.isInstance(this);
}
@@ -113,7 +114,7 @@ public record DriverDataSource(String url, String user, String password) impleme
@Override
public String toString()
{
return ("DriverDataSource[credentials redacted]");
return "DriverDataSource[credentials redacted]";
}
}
@@ -48,11 +48,11 @@ public class PrintEnqueueEffectTarget implements EffectTarget
try
{
JSONObject job = serviceForEffect().submit(payload);
return (EffectResult.sent(job.getString("jobId")));
return EffectResult.sent(job.getString("jobId"));
}
catch(PrintingException exception)
{
return (EffectResult.failed(exception.getMessage()));
return EffectResult.failed(exception.getMessage());
}
}
@@ -65,7 +65,7 @@ public class PrintEnqueueEffectTarget implements EffectTarget
public ReconcileResult reconcile(EffectRequest request) throws Exception
{
JSONObject job = serviceForEffect().findByIdempotencyKey(request.idempotencyKey());
return (job == null ? ReconcileResult.notFound() : ReconcileResult.found(job.getString("jobId")));
return job == null ? ReconcileResult.notFound() : ReconcileResult.found(job.getString("jobId"));
}
@@ -79,13 +79,13 @@ public class PrintEnqueueEffectTarget implements EffectTarget
{
if(!(QContext.getQSession() instanceof QSystemUserSession))
{
return (PrintingProcesses.currentService());
return PrintingProcesses.currentService();
}
for(QBitMetaData metadata : QContext.getQInstance().getQBits().values())
{
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null)
{
return (config.getService());
return config.getService();
}
}
throw new QException("Printing QBit is not configured for this effect dispatcher");
@@ -26,6 +26,7 @@ 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.metadata.permissions.MetaDataWithPermissionRules;
import org.jspecify.annotations.Nullable;
/*******************************************************************************
@@ -47,7 +48,7 @@ public class PrintingAdminPermission extends UseOtherPermissionNameCustomPermiss
** Enforce authorization even for direct backend-step invocation.
***************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission("printing_admin"))
{
@@ -25,6 +25,7 @@ import java.sql.Connection;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Set;
import org.jspecify.annotations.Nullable;
/*******************************************************************************
@@ -174,7 +175,7 @@ final class PrintingAdmission
/***************************************************************************
** SQL aggregates stay bounded by active capacity and the rolling hour index.
***************************************************************************/
private static void checkScope(Connection connection, String tenant, String device, int bytes, long jobLimit, long activeByteLimit, long retainedLimit, long hourlyLimit, String cutoff) throws SQLException
private static void checkScope(Connection connection, String tenant, @Nullable String device, int bytes, long jobLimit, long activeByteLimit, long retainedLimit, long hourlyLimit, String cutoff) throws SQLException
{
String predicate = "tenant_id=?" + (device == null ? "" : " AND device_id=?");
String outstanding = "SELECT content_length FROM printing_job WHERE " + predicate + " AND status IN " + ACTIVE
@@ -205,11 +206,11 @@ final class PrintingAdmission
/***************************************************************************
** Failed upload reservations stay charged until exact confirmed removal.
***************************************************************************/
private static long orphanBytes(Connection connection, String tenant, String device) throws SQLException
private static long orphanBytes(Connection connection, String tenant, @Nullable String device) throws SQLException
{
String sql = "SELECT COALESCE(SUM(COALESCE(content_length,20971520)),0) FROM printing_document_orphan o WHERE tenant_id=?"
+ (device == null ? "" : " AND device_id=?") + " AND NOT EXISTS(SELECT 1 FROM printing_job j WHERE j.id=o.id)";
return (device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device));
return device == null ? number(connection, sql, tenant) : number(connection, sql, tenant, device);
}
@@ -217,7 +218,7 @@ final class PrintingAdmission
/***************************************************************************
** Release only after deletion; an inconsistent counter fails closed.
***************************************************************************/
static void release(Connection connection, String tenant, String device, long bytes) throws SQLException
static void release(Connection connection, String tenant, @Nullable String device, long bytes) throws SQLException
{
int expected = device == null ? 1 : 2;
String predicate = device == null ? "device_id=''" : "device_id IN ('',?)";
@@ -255,7 +256,7 @@ final class PrintingAdmission
/***************************************************************************
** Read one scalar; never materialize payloads or an unbounded row list.
***************************************************************************/
private static long number(Connection connection, String sql, Object... values) throws SQLException
private static long number(Connection connection, String sql, @Nullable Object... values) throws SQLException
{
try(var statement = connection.prepareStatement(sql))
{
@@ -269,7 +270,7 @@ final class PrintingAdmission
{
throw new SQLException("Missing printing resource accounting row");
}
return (rows.getLong(1));
return rows.getLong(1);
}
}
}
@@ -279,7 +280,7 @@ final class PrintingAdmission
/***************************************************************************
** All identifiers are fixed SQL; only values come from callers.
***************************************************************************/
private static int execute(Connection connection, String sql, Object... values) throws SQLException
private static int execute(Connection connection, String sql, @Nullable Object... values) throws SQLException
{
try(var statement = connection.prepareStatement(sql))
{
@@ -287,7 +288,7 @@ final class PrintingAdmission
{
statement.setObject(index + 1, values[index]);
}
return (statement.executeUpdate());
return statement.executeUpdate();
}
}
}
@@ -48,7 +48,7 @@ public record PrintingAdmissionLimits(int deviceOutstandingJobs, int tenantOutst
public static PrintingAdmissionLimits defaults()
{
long gibibyte = 1024L * 1024 * 1024;
return (new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte,
2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000));
return new PrintingAdmissionLimits(10_000, 100_000, 10 * gibibyte, 100 * gibibyte,
2048 * gibibyte, 20_480 * gibibyte, 2_000, 20_000, 1_000, 20_000);
}
}
@@ -63,7 +63,7 @@ public class PrintingAdmissionSettings
{
try(Connection connection = source.getConnection())
{
return (json(effective(connection)));
return json(effective(connection));
}
}
@@ -85,7 +85,7 @@ public class PrintingAdmissionSettings
throw new SQLException("The tenant admission settings row is unavailable");
}
}
return (value);
return value;
}
@@ -102,10 +102,10 @@ public class PrintingAdmissionSettings
{
if(!rows.next())
{
return (defaults);
return defaults;
}
String stored = rows.getString(1);
return (stored == null ? defaults : parse(new JSONObject(stored)));
return stored == null ? defaults : parse(new JSONObject(stored));
}
}
}
@@ -123,9 +123,9 @@ public class PrintingAdmissionSettings
}
try
{
return (new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"),
return new PrintingAdmissionLimits(integer(value, "deviceOutstandingJobs"), integer(value, "tenantOutstandingJobs"),
number(value, "deviceOutstandingBytes"), number(value, "tenantOutstandingBytes"), number(value, "deviceRetainedBytes"), number(value, "tenantRetainedBytes"),
integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters")));
integer(value, "deviceJobsPerHour"), integer(value, "tenantJobsPerHour"), integer(value, "devicePrinters"), integer(value, "tenantPrinters"));
}
catch(ArithmeticException exception)
{
@@ -140,7 +140,7 @@ public class PrintingAdmissionSettings
***************************************************************************/
private static int integer(JSONObject value, String field)
{
return (Math.toIntExact(number(value, field)));
return Math.toIntExact(number(value, field));
}
@@ -159,7 +159,7 @@ public class PrintingAdmissionSettings
{
throw new IllegalArgumentException(field + " is outside the supported positive range");
}
return (parsed);
return parsed;
}
@@ -169,10 +169,10 @@ public class PrintingAdmissionSettings
***************************************************************************/
private static JSONObject json(PrintingAdmissionLimits value)
{
return (new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs())
return new JSONObject().put("deviceOutstandingJobs", value.deviceOutstandingJobs()).put("tenantOutstandingJobs", value.tenantOutstandingJobs())
.put("deviceOutstandingBytes", value.deviceOutstandingBytes()).put("tenantOutstandingBytes", value.tenantOutstandingBytes())
.put("deviceRetainedBytes", value.deviceRetainedBytes()).put("tenantRetainedBytes", value.tenantRetainedBytes())
.put("deviceJobsPerHour", value.deviceJobsPerHour()).put("tenantJobsPerHour", value.tenantJobsPerHour())
.put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters()));
.put("devicePrinters", value.devicePrinters()).put("tenantPrinters", value.tenantPrinters());
}
}
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more