Compare commits

..
Author SHA1 Message Date
CI 34fe921e0c Bump version to 0.3.0-SNAPSHOT [skip ci] 2026-11-07 20:34:48 +00:00
james.maes 39448b58f9 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#26) from deps/align-gitea into develop
build / maven (push) Successful in 2m17s
maven / build (push) Successful in 2m17s
2026-11-07 17:58:23 +00:00
james.maesandClaude Opus 5.5 094a759849 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 2m48s
maven / build (push) Successful in 2m48s
pom.xml: parent -> 2.2.1
pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.26
pom.xml: qbit-redis.version: 0.1.0-RC.1 -> 0.2.0-RC.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:55:24 +00:00
james.maes b85b9555bf Merge pull request 'Session store: hash session ids into storage keys; publish the PostgreSQL schema (KOF22 integration)' (#24) from kof22/session-codec-integration into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (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 4s
build / maven (push) Failing after 1m53s
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 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 4s
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 / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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 / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (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 / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Failing after 1m53s
2026-11-07 00:17:36 +00:00
james.maesandClaude Sonnet 5.5 375cf053a1 build: keep the JaCoCo agent on the unit tests; run the PostgreSQL IT under a UTC JVM in failsafe
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (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 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
build / maven (push) Successful in 3m17s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-core-2-head (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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-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 3m17s
The UTC argLine was set on surefire, replacing the argLine that jacoco prepare-agent
injects, so the unit tests ran without the agent and the class-coverage gate fell to
0.70 (min 0.80). The UTC setting belongs to failsafe, which runs the PostgreSQL IT;
it now appends to the jacoco argLine with @{argLine}, with an empty default property.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 18:10:29 -06:00
james.maesandClaude Sonnet 5.5 54e780e9f9 feat: hash session ids into storage keys and publish the PostgreSQL schema (KOF22 session-store integration)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (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 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
build / maven (push) Failing after 2m52s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-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) Failing after 2m52s
Brings the KOF22-validated session-store behaviour onto develop:

- SessionCodec derives the storage key from a SHA-256 of a domain-separated
  session id, so a raw session id is never stored or logged.
- StoredSessionSchema publishes the PostgreSQL DDL for the stored-session table.
- The in-memory, Redis, shared-Redis and table-based providers and the
  stored-session table metadata use them.
- Tests: SessionCodecTest, a PostgreSQL integration test for the table
  provider, and the fixture endpoint; the PostgreSQL test needs
  qqq-backend-module-postgres (test scope) and a UTC JVM.

Source and test changes only; the POM keeps develop's Core RC.22 and
qbit-redis 0.1.0-RC.1 pins.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 18:06:30 -06:00
CI 22a8ae0cf5 Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-06 15:53:50 +00:00
james.maes bf725e056a Merge pull request 'build(release): prepare the first published release (0.1.0) on core 4.1.0-RC.22' (#23) from release/first-release-20261106 into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (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 9s
build / maven (push) Successful in 52m59s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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 / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 10s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (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 / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 3m1s
2026-11-06 15:33:06 +00:00
james.maes 3161890bda Merge pull request 'ci: pin shared workflow to v1' (#22) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-3 (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-7-head (push) Canceled after 0s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Canceled after 0s
QRun-IO/ci-policy: ci / source-config-14 (push) Canceled after 0s
QRun-IO/ci-policy: ci / original-voyage (push) Canceled after 0s
QRun-IO/ci-policy: ci / original-cli (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (push) Canceled after 0s
build / maven (push) Successful in 3m52s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Canceled after 0s
QRun-IO/ci-policy: ci / repository-policy (push) Canceled after 0s
QRun-IO/ci-policy: ci / application-template-0 (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-4 (push) Canceled after 0s
QRun-IO/ci-policy: ci / node-8 (push) Canceled after 0s
QRun-IO/ci-policy: ci / node-9 (push) Canceled after 0s
QRun-IO/ci-policy: ci / node-10 (push) Canceled after 0s
QRun-IO/ci-policy: ci / repository-policy-12 (push) Canceled after 0s
QRun-IO/ci-policy: ci / source-config-13 (push) Canceled after 0s
QRun-IO/ci-policy: ci / original-business (push) Canceled after 0s
QRun-IO/ci-policy: ci / shared-pipeline (push) Canceled after 0s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Canceled after 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-6 (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-6-head (push) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-7 (push) Canceled after 0s
QRun-IO/ci-policy: ci / android-0 (push) Canceled after 0s
QRun-IO/ci-policy: ci / original-mcp (push) Canceled after 0s
QRun-IO/ci-policy: ci / not-enrolled (push) Canceled after 0s
maven / build (push) Successful in 3m52s
2026-11-06 15:32:57 +00:00
james.maesandClaude Fable 5.1 40226b3a2a build(release): prepare 0.1.0 on published core 4.1.0-RC.22 and qbit-redis 0.1.0-RC.1
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (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
build / maven (push) Failing after 7s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 7s
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 / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) 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 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 9s
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 / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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 / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 10s
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 / 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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Failing after 7s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (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 / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-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
Replace the in-flight snapshot coordinates that the shared-session work was
built against (qqq-bom-pom 4.1.0-collab-33464d37e-SNAPSHOT, qbit-redis
0.1.0-redis-7fe5492-SNAPSHOT) with published coordinates exposed as
overridable properties: qqq.version=4.1.0-RC.22, the first release-line core
carrying QSessionStoreRegistry.getInstance(QInstance) and clearIfSame, and
qbit-redis.version=0.1.0-RC.1, whose public api is a strict superset of the
7fe5492 snapshot. The revision stays 0.1.0-SNAPSHOT: the shared ci-workflows
Maven workflow assigns 0.1.0-RC.N from release/0.1 and 0.1.0 from the v0.1.0
tag, so no version is hand-edited here.

CHANGELOG records the 0.1.0 contents; README gains a compatibility matrix and
the release/publication procedure for this repository.

Verification (Maven 3.9.11, Temurin 21, Docker 29.9.2 for Testcontainers):
mvn verify with the same sources installed locally under local-labeled
versions (core release/4.1 tip f1cdf31e9 as 4.1.0-local.f1cdf31e9, qbit-redis
tag v0.1.0-RC.1 as 0.1.0-local.6b0814d, because the Gitea registry needs an
authenticated read from this host): 67 unit tests, 29 integration tests
(RedisSessionStoreProviderIT, SharedRedisSessionStoreProviderIT,
TableBasedSessionStoreProviderIT) with zero failures/errors/skips, zero
Checkstyle violations, JaCoCo coverage gates met.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:53:30 -06:00
james.maes f97c7c41a4 fix: upgrade session-store test assertions to patched AssertJ
Use3.27.7 for GHSA-rqfh-9r24-8c9r. Verified synthetic external-entity/DTD rejection, ordinary XML control, dependency resolution,67unit and29integration tests with coverage.

Co-Authored-By: Codex <noreply@openai.com>
(cherry picked from commit 0f50ca15af)
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:48:25 -06:00
james.maesandClaude Fable 5.1 3f68fff26f Merge branch 'feature/session' into release/first-release-20261106
Bring the application-scoped shared Redis session work (b5276ab101, the
exact source behind GG Forge's qbit-session-store 0.1.0-forge pin) onto the
release line. Conflicts in .circleci/config.yml and .qqq-ci.yml are resolved
in favor of develop, which already moved to the shared Gitea ci-workflows
Maven workflow; the feature branch's CircleCI publish-tag filters are not
carried over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:48:15 -06:00
james.maesandClaude Opus 5.5 42de6a5464 ci: pin shared workflow to v1
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 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
build / maven (push) Successful in 2m56s
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-core-2-head (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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 9s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-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 2m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:13:46 +00:00
james.maes cbb7e26bf3 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#21) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (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: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (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 5s
build / maven (push) Successful in 2m6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
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 / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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 / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (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 / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 2m6s
2026-11-05 23:18:05 +00:00
CI 4ae12fb854 ci: rerun shared workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / head-profile (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 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 1s
build / maven (push) Successful in 1m46s
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
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 / 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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-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 1m46s
2026-11-05 16:27:03 -06:00
james.maesandClaude Opus 5.5 54f4b0656b ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
build / maven (push) Failing after 2s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / head-profile (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 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
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 / 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 / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
maven / build (push) Failing after 2s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-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
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-05 22:08:23 +00:00
forge-recovery 53d7e59343 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 33m23s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
qrun-release / postpublish (push) Failing after 10m17s
Shared Java Maven CI with Docker / verify (push) Successful in 2m3s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 13s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
qrun-release / release (push) Successful in 33m41s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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 / 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: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 17s
Shared Java Maven CI with Docker / gate (push) Successful in 14s
Shared QRun source version / version (push) Failing after 10m17s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 1m56s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (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 / original-mcp (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
2026-11-02 22:23:39 +00:00
forge-recovery 4911617eb9 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 29s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 18m35s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (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 / 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: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (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 / original-mcp (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 with Docker / verify (push) Successful in 2m2s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 18s
2026-11-02 21:34:21 +00:00
forge-recoveryandCodex 2b5f6ca241 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (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: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
Release candidate / release-rc (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 / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 16s
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 / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m19s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 1h5m30s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:20:13 +00:00
b2382a850b ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
Release candidate / release-rc (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 / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
qrun-release / release (push) Canceled after 2h29m35s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
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 / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 17s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m6s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 3h7m8s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:52:55 +00:00
d44498c7b3 ci: move the QRun release caller to the thin v2 contract
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: ci / node-playwright-8 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 11s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
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 / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 23m41s
qrun-release / release (push) Canceled after 23m46s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:20:52 +00:00
forge-recoveryandCodex 8e6d0ba1c9 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: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Successful in 54m43s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
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: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m30s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 14s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:14:44 +00:00
james.maesandCodex 41813daacc ci: adopt managed orb 0.8 patch track (#16)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 16:35:46 -05:00
james.maesandCodex e19beedc14 ci: adopt central orb 0.7.12 and stable gates (#15)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:55:53 -05:00
james.maesandCodex fc92c24132 ci: centralize rc release completion in qqq orb (#14)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:24:19 -05:00
james.maesandCodex 49f87072eb ci: publish GA artifacts only from stable tags (#13)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:09:12 -05:00
james.maesandCodex 703e16d849 ci: adopt shared orb 0.7.11 (#12)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:05 -05:00
james.maes c78ab8c6b7 Merge pull request #11 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:21:31 -05:00
james.maesandCodex a9adc28792 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:08:24 -05:00
20 changed files with 1617 additions and 258 deletions

No files matched your search

+3 -5
View File
@@ -1,16 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.18
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.18
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only:
- /^v\d+\.\d+\.\d+$/
- /publish.*/
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+1 -2
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: 0.7.18
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
@@ -10,7 +10,6 @@ release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
feature: true
release_candidate: true
release: true
asciidoc: false
+25 -1
View File
@@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - 0.1.0-SNAPSHOT
## [Unreleased] - 0.1.0
First published release. Version numbers are assigned by the shared Gitea
Maven workflow from the branch or tag that publishes (see README "Releasing");
the `revision` property on `develop` stays `0.1.0-SNAPSHOT` until the release
merges back.
### Added
- In-memory session store provider with LRU eviction
@@ -15,3 +20,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sliding expiration (configurable per-provider)
- Scheduled cleanup process for expired sessions
- QBit producer for declarative configuration
- `SHARED_REDIS` provider: application-scoped shared sessions on the pool owned
by the optional `qbit-redis` QBit, with explicit deployment/application/
authentication-domain namespacing, bounded payloads, atomic GETEX touch, and a
runtime service that releases registrations on stop and rebuilds them on
restart (`docs/SESSION-REGISTRATION.md`)
- Explicit `QInstance` overloads on `QSessionStoreQBitContext` and
application-scoped registration through
`QSessionStoreRegistry.getInstance(qInstance)`; two applications in one JVM
no longer see each other's sessions
- Health check reports the target application selected by `HealthCheckInput`
### Changed
- Depends on published QQQ core `4.1.0-RC.22` (the first release-line core with
`QSessionStoreRegistry.getInstance(QInstance)`) and `qbit-redis 0.1.0-RC.1`;
the snapshot coordinates used while the shared-session work was in flight
(`4.1.0-collab-33464d37e-SNAPSHOT`, `0.1.0-redis-7fe5492-SNAPSHOT`) are gone
- Jedis 7.2.0, Testcontainers 2.0.3, AssertJ 3.27.7 (GHSA-rqfh-9r24-8c9r)
- Hosts that register a provider with core outside a request context must use
the explicit `QSessionStoreRegistry.getInstance(qInstance)` overload
+36 -2
View File
@@ -8,7 +8,21 @@ Every QQQ request authenticates and builds a `QSession` containing security keys
## Project Status
**0.1.0-SNAPSHOT** -- under active development. API may change before 1.0.
**0.1.0** is the first published release line (release candidates publish as
`0.1.0-RC.N`). API may change before 1.0. See [CHANGELOG.md](CHANGELOG.md).
## Compatibility
| qbit-session-store | QQQ core (`qqq-bom-pom`) | qbit-redis (optional, `SHARED_REDIS` only) |
|--------------------|--------------------------|--------------------------------------------|
| 0.1.0 line | 4.1.0-RC.22 or later 4.1 | 0.1.0-RC.1 or later 0.1 |
The `SHARED_REDIS` provider and application-scoped registration call
`QSessionStoreRegistry.getInstance(QInstance)` and `clearIfSame`, which core
added after 4.1.0-RC.21; this QBit does not compile against RC.21 or earlier.
Artifacts publish to the Gitea Maven registry
(`https://git.kof22.com/api/packages/QRun-IO/maven`), which requires an
authenticated read.
## Features
@@ -35,7 +49,7 @@ Add the dependency to your project:
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-session-store</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.1.0</version>
</dependency>
```
@@ -227,6 +241,26 @@ provider.remove(sessionUuid);
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Releasing
Versions are computed by the shared `KofTwentyTwo/ci-workflows` Maven workflow
(`.gitea/workflows/build.yaml`) from what is pushed; the `revision` property on
`develop` stays `0.1.0-SNAPSHOT` and is never hand-edited for a release.
| Push | Published version | Side effects |
|----------------------------|--------------------------|---------------------------------------------------------------|
| `develop` | `0.1.0-SNAPSHOT` | none |
| branch `release/0.1` | `0.1.0-RC.1`, `RC.2`, .. | commits "Bump version to X [skip ci]" to the branch, tags `vX` |
| tag `v0.1.0` | `0.1.0` | pushes the version bump to `main` |
| tag `publish-<name>` | `0.1.0-<feature>-<sha>-SNAPSHOT` | none |
To cut the first release: merge this release preparation into `develop`, create
`release/0.1` from `develop` and push it (publishes `0.1.0-RC.1`), qualify the
candidate, then tag the bumped `release/0.1` head `v0.1.0` and push the tag
(publishes `0.1.0`). Merge `release/0.1` back into `develop`; the workflow then
advances `develop` to `0.2.0-SNAPSHOT`. Record the date in `CHANGELOG.md` when
tagging. GitHub is a read-only shadow; never push there.
## License
Apache-2.0 - See [LICENSE](LICENSE)
-50
View File
@@ -1,50 +0,0 @@
# Shared sessions during controlled Redis promotion
Tracks [QQQ #1301](https://github.com/QRun-IO/qqq/issues/1301). This is a test of
the actual shared session provider, complementary to the connection and Pub/Sub
promotion fixture in Redis draft PR4. No production provider behavior changes.
`SharedRedisSessionStoreProviderIT.replicatedSessionsAndRevocationsSurviveControlledPromotion`
uses a real Redis primary/replica pair and three separately configured QQQ
instances. One writes to the primary; two use the replica endpoint, with fixed
and sliding expiry settings. The test verifies:
- Session data reaches the replica and writes to the read-only replica fail.
- A removed session is absent after a subsequent write reaches the replica.
This observation establishes that this test's deletion was replicated.
- After the old primary stops, its provider raises an infrastructure failure
rather than reporting a normal session miss.
- After promotion, the original session remains readable and the replicated
revocation remains absent. Touch/read-and-touch cannot recreate it.
- New sessions, cross-node deletion, sliding expiry and natural expiry work on
the promoted endpoint. Touch cannot recreate an expired session.
Verification passed on October 26, 2026: 67 unit tests and 30 integration tests,
no failures/errors/skips, zero Checkstyle violations, and coverage gates passed.
The first full run encountered a replica read timeout during initial loading;
the fixture now retries connection failures only inside its bounded 15-second
initial replication wait. Post-startup assertions and runtime timeouts are
unchanged. The focused test also passed before that startup adjustment.
Containers, network, provider registrations and runtime pools belong to the
fixture and are cleaned up by it. The existing Maven wrapper from the core
checkout can run this repository's POM on Windows, where this checkout has no
`mvnw.cmd`.
## Limits and remaining qualification
The test deliberately waits for replication before stopping the primary. It
does not prove that unreplicated session creation, permission changes or logout
survive failover. Redis replication is asynchronous; recovered connectivity
alone does not establish durable authentication state. See the official
[replication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/).
Before production failover support, qualify the actual host endpoint routing,
old-primary fencing, TLS/authentication, and the policy for lost or restored
authentication state. In particular, a logout lost before replication can leave
an older session copy on the promoted node; this fixture must not be cited as
proof that such a session cannot return. Presence generation rotation alone
does not invalidate session-store data.
This uses an unchanged explicit replica endpoint and controlled promotion. It
does not implement native Sentinel discovery, Cluster routing or automatic
application reconfiguration, and does not qualify a deployed Kof22 cluster.
+21 -6
View File
@@ -18,17 +18,24 @@
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<revision>0.3.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<argLine></argLine>
<jedis.version>7.2.0</jedis.version>
<qqq.snapshot.version>4.1.0-collab-33464d37e-SNAPSHOT</qqq.snapshot.version>
<!-- Published QQQ 4.1 core. 4.1.0-RC.22 is the first release-line core that carries
QSessionStoreRegistry.getInstance(QInstance) and clearIfSame, which the
application-scoped SHARED_REDIS provider requires (RC.21 and earlier do not compile). -->
<qqq.version>4.1.0-RC.26</qqq.version>
<!-- Published qbit-redis release candidate whose public api (RedisAccess, RedisCache,
RedisNamespace) the SHARED_REDIS provider is built against. -->
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>1.4.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -79,7 +86,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<version>${qqq.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -95,7 +102,7 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-redis</artifactId>
<version>0.1.0-redis-7fe5492-SNAPSHOT</version>
<version>${qbit-redis.version}</version>
<optional>true</optional>
</dependency>
@@ -111,7 +118,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<version>${qqq.snapshot.version}</version>
<version>${qqq.version}</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -119,6 +126,11 @@
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
@@ -186,6 +198,9 @@
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</includes>
<!-- PostgreSQL instants (TableBasedSessionStorePostgresIT) are only correct under a UTC JVM.
@{argLine} keeps the JaCoCo agent that prepare-agent puts in the argLine property. -->
<argLine>@{argLine} -Duser.timezone=UTC</argLine>
</configuration>
<executions>
<execution>
@@ -0,0 +1,151 @@
/*
* Copyright 2024 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
*
* http://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.
*/
package com.kingsrook.qbits.sessionstore;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.json.JSONObject;
/*******************************************************************************
** Shared at-rest rules for every persistent provider.
**
** A session id is the credential that resumes the session (the cookie value of a
** native login), so it is never written to a table, Redis key, log line or the
** stored JSON. Providers address a session by {@link #key(String)} (a one-way
** digest) and store {@link #encode(String, QSession)}, an envelope whose session
** has its own uuid and id reference removed; {@link #decode(String, String)}
** restores them from the id the caller presented. A reader that does not hold
** the id therefore cannot resume anything from a copy of the store, and an
** envelope read under another id is refused.
*******************************************************************************/
public final class SessionCodec
{
private static final String KEY_DOMAIN = "qbit-session-store:v1:";
private static final int VERSION = 1;
private static final int LOG_LENGTH = 12;
/***************************************************************************
** Utility class.
***************************************************************************/
private SessionCodec()
{
}
/***************************************************************************
** The storage key for a session id: lower-case hex SHA-256 of a domain
** separated id.
***************************************************************************/
public static String key(String sessionId)
{
Objects.requireNonNull(sessionId, "Session id is required");
if(sessionId.isBlank())
{
throw new IllegalArgumentException("Session id is required");
}
try
{
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((KEY_DOMAIN + sessionId).getBytes(StandardCharsets.UTF_8)));
}
catch(NoSuchAlgorithmException e)
{
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
/***************************************************************************
** A short non-reversible tag for log lines.
***************************************************************************/
public static String logTag(String sessionId)
{
return sessionId == null || sessionId.isBlank() ? "none" : key(sessionId).substring(0, LOG_LENGTH);
}
/***************************************************************************
** Serialize a session for storage under the given id, without the id.
***************************************************************************/
public static String encode(String sessionId, QSession session)
{
Objects.requireNonNull(session, "Session is required");
JSONObject stored = new JSONObject(JsonUtils.toJson(session));
boolean uuidStripped = sessionId.equals(stored.optString("uuid", null));
boolean refStripped = sessionId.equals(stored.optString("idReference", null));
if(uuidStripped)
{
stored.remove("uuid");
}
if(refStripped)
{
stored.remove("idReference");
}
return new JSONObject()
.put("v", VERSION)
.put("keyDigest", key(sessionId))
.put("uuidIsId", uuidStripped)
.put("idReferenceIsId", refStripped)
.put("session", stored)
.toString();
}
/***************************************************************************
** Restore a stored envelope for the id the caller presented; anything that
** is not an envelope for exactly this id is refused.
***************************************************************************/
public static QSession decode(String sessionId, String json) throws IOException
{
JSONObject envelope;
try
{
envelope = new JSONObject(json);
}
catch(RuntimeException e)
{
throw new IOException("Stored session is not readable", e);
}
if(envelope.optInt("v", -1) != VERSION || !envelope.has("session") || !key(sessionId).equals(envelope.optString("keyDigest", null)))
{
throw new IOException("Stored session does not belong to the presented id");
}
JSONObject stored = envelope.getJSONObject("session");
if(envelope.optBoolean("uuidIsId"))
{
stored.put("uuid", sessionId);
}
if(envelope.optBoolean("idReferenceIsId"))
{
stored.put("idReference", sessionId);
}
return JsonUtils.toObject(stored.toString(), QSession.class);
}
}
@@ -0,0 +1,175 @@
/*
* Copyright 2024 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
*
* http://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.
*/
package com.kingsrook.qbits.sessionstore;
import java.util.ArrayList;
import java.util.List;
import java.util.function.UnaryOperator;
import com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer;
import com.kingsrook.qbits.sessionstore.model.StoredSession;
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.tables.QTableMetaData;
/*******************************************************************************
** The PostgreSQL schema of the TABLE_BASED provider, derived from the very table
** metadata the producer installs, so the two cannot drift apart. A migration
** identity (the schema owner) applies it once; the provider itself only ever does
** DML and needs no DDL, history or ownership rights.
**
** QQQ quotes every identifier, so by default the physical names are exactly the
** metadata names (quoted). An application that maps the table onto another naming
** rule (for example snake_case through RDBMSTableBackendDetails and field backend
** names) passes that rule as the naming function, and gets the matching DDL.
*******************************************************************************/
public final class StoredSessionSchema
{
private static final UnaryOperator<String> EXACT = UnaryOperator.identity();
/***************************************************************************
** Utility class.
***************************************************************************/
private StoredSessionSchema()
{
}
/***************************************************************************
** The physical table name (exact metadata name).
***************************************************************************/
public static String physicalTableName(QSessionStoreQBitConfig config)
{
return physicalTableName(config, EXACT);
}
/***************************************************************************
** The physical table name under a naming rule.
***************************************************************************/
public static String physicalTableName(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
return naming.apply(config.applyPrefix(config.getTableName()));
}
/***************************************************************************
** The physical column names (exact metadata names).
***************************************************************************/
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config)
{
return physicalColumnNames(config, EXACT);
}
/***************************************************************************
** The physical column names under a naming rule.
***************************************************************************/
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
List<String> names = new ArrayList<>();
for(QFieldMetaData field : table(config).getFields().values())
{
names.add(naming.apply(field.getName()));
}
return names;
}
/***************************************************************************
** Idempotent DDL with exact names.
***************************************************************************/
public static String postgresDdl(QSessionStoreQBitConfig config)
{
return postgresDdl(config, EXACT);
}
/***************************************************************************
** Idempotent DDL under a naming rule: the table, its unique key on the
** session-key digest and an index serving expiry cleanup. Identifiers are
** always quoted, as QQQ quotes them in its own statements.
***************************************************************************/
public static String postgresDdl(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
QTableMetaData table = table(config);
String name = physicalTableName(config, naming);
String key = naming.apply(StoredSession.FIELD_SESSION_UUID);
String exp = naming.apply(StoredSession.FIELD_EXPIRES_AT);
List<String> lines = new ArrayList<>();
for(QFieldMetaData field : table.getFields().values())
{
lines.add(" " + quote(naming.apply(field.getName())) + " " + type(table, field));
}
lines.add(" CONSTRAINT " + quote("uq_" + name + "_" + key) + " UNIQUE (" + quote(key) + ")");
return "CREATE TABLE IF NOT EXISTS " + quote(name) + " (\n" + String.join(",\n", lines) + "\n);\n"
+ "CREATE INDEX IF NOT EXISTS " + quote("ix_" + name + "_" + exp) + " ON " + quote(name) + " (" + quote(exp) + ");\n";
}
/***************************************************************************
** Quote an identifier.
***************************************************************************/
public static String quote(String identifier)
{
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
/***************************************************************************
** The producer's table, built in a scratch instance.
***************************************************************************/
private static QTableMetaData table(QSessionStoreQBitConfig config)
{
QInstance scratch = new QInstance();
StoredSessionTableMetaDataProducer.produce(scratch, config);
return scratch.getTable(config.applyPrefix(config.getTableName()));
}
/***************************************************************************
** Column type and constraints of one field.
***************************************************************************/
private static String type(QTableMetaData table, QFieldMetaData field)
{
if(field.getName().equals(table.getPrimaryKeyField()))
{
return "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY";
}
String base = switch(field.getType())
{
case STRING, PASSWORD -> field.getMaxLength() == null ? "TEXT" : "VARCHAR(" + field.getMaxLength() + ")";
case TEXT, HTML -> "TEXT";
case DATE_TIME -> "TIMESTAMP WITH TIME ZONE";
default -> throw new IllegalStateException("Unsupported stored-session field type " + field.getType());
};
return base + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "");
}
}
@@ -22,12 +22,19 @@ import com.kingsrook.qbits.sessionstore.model.StoredSession;
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.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Metadata producer for the StoredSession table.
**
** The table is internal: hidden, and protected by its own permission set that no
** role is given (the provider reads and writes it as the system user). Its rows
** are session-key digests and id-free envelopes, never session ids.
*******************************************************************************/
public class StoredSessionTableMetaDataProducer
{
@@ -42,6 +49,11 @@ public class StoredSessionTableMetaDataProducer
QTableMetaData table = new QTableMetaData()
.withName(tableName)
.withLabel("Stored Session")
.withIsHidden(true)
.withPermissionRules(new QPermissionRules()
.withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)
.withPermissionBaseName(tableName)
.withDenyBehavior(DenyBehavior.HIDDEN))
.withBackendName(config.getBackendName())
.withPrimaryKeyField(StoredSession.FIELD_ID)
.withRecordLabelFormat("%s")
@@ -49,10 +61,10 @@ public class StoredSessionTableMetaDataProducer
.withUniqueKey(new UniqueKey(StoredSession.FIELD_SESSION_UUID))
.withField(new QFieldMetaData(StoredSession.FIELD_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(StoredSession.FIELD_SESSION_UUID, QFieldType.STRING)
.withLabel("Session UUID")
.withLabel("Session Key (digest)")
.withIsRequired(true)
.withIsEditable(false)
.withMaxLength(36))
.withMaxLength(64))
.withField(new QFieldMetaData(StoredSession.FIELD_USER_ID, QFieldType.STRING)
.withLabel("User ID")
.withIsEditable(false))
@@ -25,6 +25,7 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -57,6 +58,24 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
/***************************************************************************
** A request that mutates its session must not change what another request
** will load, so the store holds and returns copies.
***************************************************************************/
private static QSession copy(QSession session)
{
try
{
return session.clone();
}
catch(CloneNotSupportedException e)
{
throw new IllegalStateException("Session cannot be copied", e);
}
}
/***************************************************************************
** Configure the provider.
***************************************************************************/
@@ -99,8 +118,8 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
Instant expiresAt = Instant.now().plus(effectiveTtl);
cache.put(sessionUuid, new CachedSession(session, expiresAt));
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
cache.put(sessionUuid, new CachedSession(copy(session), expiresAt));
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
}
@@ -120,11 +139,11 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
if(cached.isExpired())
{
cache.remove(sessionUuid);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.empty();
}
return Optional.of(cached.session());
return Optional.of(copy(cached.session()));
}
@@ -136,7 +155,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
public void remove(String sessionUuid)
{
cache.remove(sessionUuid);
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
}
@@ -152,7 +171,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
{
Instant newExpiresAt = Instant.now().plus(defaultTtl);
cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt));
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
}
}
@@ -184,7 +203,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
if(cached.isExpired())
{
cache.remove(sessionUuid);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.empty();
}
@@ -193,9 +212,9 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
//////////////////////////////////////////////////////////////////////
Instant newExpiresAt = Instant.now().plus(defaultTtl);
cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt));
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Loaded and touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
return Optional.of(cached.session());
return Optional.of(copy(cached.session()));
}
@@ -20,9 +20,9 @@ import java.time.Duration;
import java.util.Optional;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
@@ -82,7 +82,7 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
***************************************************************************/
private String buildKey(String sessionUuid)
{
return keyPrefix + sessionUuid;
return keyPrefix + SessionCodec.key(sessionUuid);
}
@@ -97,14 +97,14 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
String key = buildKey(sessionUuid);
String sessionJson = JsonUtils.toJson(session);
String sessionJson = SessionCodec.encode(sessionUuid, session);
jedis.setex(key, effectiveTtl.toSeconds(), sessionJson);
LOG.debug("Stored session in Redis", logPair("sessionUuid", sessionUuid), logPair("ttlSeconds", effectiveTtl.toSeconds()));
LOG.debug("Stored session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("ttlSeconds", effectiveTtl.toSeconds()));
}
catch(Exception e)
{
LOG.warn("Failed to store session in Redis", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to store session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
}
}
@@ -126,12 +126,12 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
return Optional.empty();
}
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load session from Redis", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to load session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
return Optional.empty();
}
}
@@ -148,11 +148,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
String key = buildKey(sessionUuid);
jedis.del(key);
LOG.debug("Removed session from Redis", logPair("sessionUuid", sessionUuid));
LOG.debug("Removed session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)));
}
catch(Exception e)
{
LOG.warn("Failed to remove session from Redis", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to remove session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
}
}
@@ -168,11 +168,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
String key = buildKey(sessionUuid);
jedis.expire(key, defaultTtl.toSeconds());
LOG.debug("Touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
LOG.debug("Touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
}
catch(Exception e)
{
LOG.warn("Failed to touch session in Redis", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
}
}
@@ -229,13 +229,13 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
return Optional.empty();
}
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
LOG.debug("Loaded and touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
LOG.debug("Loaded and touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load and touch session in Redis", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to load and touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
return Optional.empty();
}
}
@@ -27,10 +27,10 @@ import com.kingsrook.qbits.redis.api.RedisCache;
import com.kingsrook.qbits.redis.api.RedisNamespace;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
@@ -115,7 +115,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public void store(String id, QSession session, Duration ttl)
{
cache().put(id, JsonUtils.toJson(Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
cache().put(SessionCodec.key(id), SessionCodec.encode(id, Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
}
@@ -126,7 +126,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public Optional<QSession> load(String id)
{
return decode(cache().get(id));
return decode(id, cache().get(SessionCodec.key(id)));
}
@@ -137,7 +137,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public void remove(String id)
{
cache().remove(id);
cache().remove(SessionCodec.key(id));
}
@@ -148,7 +148,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public void touch(String id)
{
cache().touch(id, defaultTtl);
cache().touch(SessionCodec.key(id), defaultTtl);
}
@@ -159,7 +159,8 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
@Override
public Optional<QSession> loadAndTouch(String id)
{
return decode(sliding ? cache().getAndTouch(id, defaultTtl) : cache().get(id));
String key = SessionCodec.key(id);
return decode(id, sliding ? cache().getAndTouch(key, defaultTtl) : cache().get(key));
}
@@ -167,7 +168,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
/***************************************************************************
** Bound decoded payloads and never include session contents in error messages.
***************************************************************************/
private Optional<QSession> decode(Optional<byte[]> value)
private Optional<QSession> decode(String id, Optional<byte[]> value)
{
return value.map(bytes ->
{
@@ -177,7 +178,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi
}
try
{
return Objects.requireNonNull(JsonUtils.toObject(new String(bytes, StandardCharsets.UTF_8), QSession.class));
return Objects.requireNonNull(SessionCodec.decode(id, new String(bytes, StandardCharsets.UTF_8)));
}
catch(IOException | RuntimeException e)
{
@@ -16,23 +16,26 @@
package com.kingsrook.qbits.sessionstore.providers;
import java.io.Serializable;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.model.StoredSession;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
@@ -40,19 +43,22 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Table-based session store provider using QQQ table actions.
** Best for: multi-instance deployments, persistence across restarts.
**
** Rows are addressed by {@link SessionCodec#key(String)} and hold an envelope that
** does not contain the session id, so the table never holds a credential that
** resumes a session. Storing a session that is already present replaces its data:
** a stale copy (old permissions, old tenant keys) is never left in place, and when
** a store cannot complete the previous copy is removed.
*******************************************************************************/
public class TableBasedSessionStoreProvider implements QSessionStoreProviderInterface
{
@@ -61,6 +67,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
private QSessionStoreQBitConfig config;
private String tableName;
private Duration defaultTtl;
private boolean sliding;
@@ -73,85 +80,196 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
this.config = config;
this.tableName = config.applyPrefix(config.getTableName());
this.defaultTtl = config.getDefaultTtl();
this.sliding = !Boolean.FALSE.equals(config.getEnableSlidingExpiration());
LOG.info("TableBased session store configured", logPair("tableName", tableName));
}
/***************************************************************************
** Store a session with the given TTL.
** Store a session with the given TTL, replacing any existing copy.
***************************************************************************/
@Override
public void store(String sessionUuid, QSession session, Duration ttl)
{
String key = SessionCodec.key(sessionUuid);
try
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
Instant expiresAt = Instant.now().plus(effectiveTtl);
String sessionJson = JsonUtils.toJson(session);
QRecord record = new QRecord()
.withValue(StoredSession.FIELD_SESSION_UUID, sessionUuid)
.withValue(StoredSession.FIELD_USER_ID, session.getUser() != null ? session.getUser().getIdReference() : null)
.withValue(StoredSession.FIELD_SESSION_DATA, sessionJson)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt);
Instant expiresAt = Instant.now().plus(effectiveTtl);
String stored = SessionCodec.encode(sessionUuid, session);
String userId = session.getUser() != null ? session.getUser().getIdReference() : null;
runWithSystemSession(() ->
{
InsertInput insertInput = new InsertInput();
insertInput.setTableName(tableName);
insertInput.setRecords(List.of(record));
new InsertAction().execute(insertInput);
try
{
upsert(key, userId, stored, expiresAt);
}
catch(QException e)
{
///////////////////////////////////////////////////////////////////////
// two instances storing the same new session race on the unique key: //
// the loser retries once, as an update //
///////////////////////////////////////////////////////////////////////
upsert(key, userId, stored, expiresAt);
}
return null;
});
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
}
catch(Exception e)
{
LOG.warn("Failed to store session", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to store session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
remove(sessionUuid);
}
}
/***************************************************************************
** Load a session by UUID.
** Insert or update the one row for a key.
***************************************************************************/
private void upsert(String key, String userId, String stored, Instant expiresAt) throws QException
{
QRecord existing = get(key);
if(existing == null)
{
InsertInput insertInput = new InsertInput();
insertInput.setTableName(tableName);
insertInput.setRecords(List.of(new QRecord()
.withValue(StoredSession.FIELD_SESSION_UUID, key)
.withValue(StoredSession.FIELD_USER_ID, userId)
.withValue(StoredSession.FIELD_SESSION_DATA, stored)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt)));
requireNoErrors(new InsertAction().execute(insertInput).getRecords());
}
else
{
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", existing.getValue("id"))
.withValue(StoredSession.FIELD_USER_ID, userId)
.withValue(StoredSession.FIELD_SESSION_DATA, stored)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt)));
requireNoErrors(new UpdateAction().execute(updateInput).getRecords());
}
}
/***************************************************************************
** A record-level error (unique key, constraint) is a failed store.
***************************************************************************/
private static void requireNoErrors(List<QRecord> records) throws QException
{
for(QRecord record : records)
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw new QException("Session store write was rejected");
}
}
}
/***************************************************************************
** The stored row for a storage key, or null.
***************************************************************************/
private QRecord get(String key) throws QException
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, key));
GetOutput getOutput = new GetAction().execute(getInput);
return getOutput.getRecord();
}
/***************************************************************************
** Load a session by id.
***************************************************************************/
@Override
public Optional<QSession> load(String sessionUuid)
{
return read(sessionUuid, false);
}
/***************************************************************************
** Load a session and, when sliding expiration is on, renew it.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String sessionUuid)
{
return read(sessionUuid, sliding);
}
/***************************************************************************
** Shared read: expired, unreadable and mismatched rows are removed and
** reported as a miss.
***************************************************************************/
private Optional<QSession> read(String sessionUuid, boolean touch)
{
String key = SessionCodec.key(sessionUuid);
try
{
QRecord record = runWithSystemSession(() ->
return runWithSystemSession(() ->
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
return getOutput.getRecord();
QRecord record = get(key);
if(record == null)
{
return Optional.<QSession>empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
deleteByKey(key);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.<QSession>empty();
}
QSession session;
try
{
session = SessionCodec.decode(sessionUuid, record.getValueString(StoredSession.FIELD_SESSION_DATA));
String storedUser = record.getValueString(StoredSession.FIELD_USER_ID);
String sessionUser = session.getUser() != null ? session.getUser().getIdReference() : null;
if(!Objects.equals(storedUser, sessionUser))
{
throw new IOException("Stored session identity does not match its row");
}
}
catch(IOException | RuntimeException e)
{
deleteByKey(key);
LOG.warn("Discarded an unreadable stored session", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.<QSession>empty();
}
if(touch)
{
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, Instant.now().plus(defaultTtl))));
new UpdateAction().execute(updateInput);
}
return Optional.of(session);
});
if(record == null)
{
return Optional.empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
remove(sessionUuid);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA);
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load session", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to load session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
return Optional.empty();
}
}
@@ -159,7 +277,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
/***************************************************************************
** Remove a session by UUID.
** Remove a session by id.
***************************************************************************/
@Override
public void remove(String sessionUuid)
@@ -168,26 +286,35 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
{
runWithSystemSession(() ->
{
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid)));
new DeleteAction().execute(deleteInput);
deleteByKey(SessionCodec.key(sessionUuid));
return null;
});
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
}
catch(Exception e)
{
LOG.warn("Failed to remove session", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to remove session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
}
}
/***************************************************************************
** Touch a session to reset its TTL.
** Delete the row for a storage key.
***************************************************************************/
private void deleteByKey(String key) throws QException
{
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, key)));
new DeleteAction().execute(deleteInput);
}
/***************************************************************************
** Touch a session to reset its TTL; never recreates a missing session.
***************************************************************************/
@Override
public void touch(String sessionUuid)
@@ -198,33 +325,24 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
runWithSystemSession(() ->
{
/////////////////////////////////////////////////////////////////
// First get the record to find its primary key //
/////////////////////////////////////////////////////////////////
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
if(getOutput.getRecord() != null)
QRecord record = get(SessionCodec.key(sessionUuid));
if(record != null)
{
QRecord updateRecord = new QRecord()
.withValue("id", getOutput.getRecord().getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(updateRecord));
updateInput.setRecords(List.of(new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt)));
new UpdateAction().execute(updateInput);
}
return null;
});
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
}
catch(Exception e)
{
LOG.warn("Failed to touch session", logPair("sessionUuid", sessionUuid), e);
LOG.warn("Failed to touch session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
}
}
@@ -241,83 +359,6 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
/***************************************************************************
** Load a session and touch it in a single operation.
**
** Optimized to perform both load and TTL update in one database round-trip
** by updating the expiresAt during the same get+update operation.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String sessionUuid)
{
try
{
return runWithSystemSession(() ->
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
QRecord record = getOutput.getRecord();
if(record == null)
{
return Optional.empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
///////////////////////////////
// Session is expired, clean up //
///////////////////////////////
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid)));
new DeleteAction().execute(deleteInput);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.<QSession>empty();
}
///////////////////////////////
// Update expiresAt (touch) //
///////////////////////////////
Instant newExpiresAt = Instant.now().plus(defaultTtl);
QRecord updateRecord = new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(updateRecord));
new UpdateAction().execute(updateInput);
///////////////////////////////
// Return the session //
///////////////////////////////
String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA);
try
{
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
return Optional.of(session);
}
catch(java.io.IOException e)
{
throw new QException("Failed to deserialize session", e);
}
});
}
catch(Exception e)
{
LOG.warn("Failed to load and touch session", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
/***************************************************************************
** Clean up expired sessions.
***************************************************************************/
@@ -356,11 +397,9 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
{
long count = runWithSystemSession(() ->
{
QueryInput queryInput = new QueryInput();
queryInput.setTableName(tableName);
queryInput.setFilter(new QQueryFilter());
QueryOutput queryOutput = new QueryAction().execute(queryInput);
return (long) queryOutput.getRecords().size();
CountInput countInput = new CountInput();
countInput.setTableName(tableName);
return new CountAction().execute(countInput).getCount().longValue();
});
return "TableBased: " + count + " sessions in " + tableName;
}
@@ -0,0 +1,110 @@
/*
* Copyright 2024 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
*
* http://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.
*/
package com.kingsrook.qbits.sessionstore;
import java.io.IOException;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Unit tests for the at-rest rules shared by the persistent providers.
*******************************************************************************/
class SessionCodecTest
{
private static final String ID = "3f1b6d3c-6b0e-4f2d-9d52-0a7d9ad1c001";
/***************************************************************************
** The key is a stable one-way digest that does not contain the id.
***************************************************************************/
@Test
void testKeyIsStableDigestWithoutId()
{
String key = SessionCodec.key(ID);
assertThat(key).matches("[0-9a-f]{64}").doesNotContain(ID).isEqualTo(SessionCodec.key(ID)).isNotEqualTo(SessionCodec.key(ID + "x"));
assertThat(SessionCodec.logTag(ID)).hasSize(12).doesNotContain(ID);
assertThat(SessionCodec.logTag(null)).isEqualTo("none");
assertThatThrownBy(() -> SessionCodec.key(" ")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SessionCodec.key(null)).isInstanceOf(NullPointerException.class);
}
/***************************************************************************
** The stored text never contains the id, and decoding restores it.
***************************************************************************/
@Test
void testEncodeRemovesIdAndDecodeRestoresIt() throws IOException
{
QSession session = new QSession();
session.setUuid(ID);
session.setIdReference(ID);
session.setUser(new QUser().withIdReference("alice"));
session.setPermissions(Set.of("p.read"));
session.setValue("tenant", "t-1");
String stored = SessionCodec.encode(ID, session);
assertThat(stored).doesNotContain(ID).contains("alice").contains("p.read");
QSession decoded = SessionCodec.decode(ID, stored);
assertThat(decoded.getUuid()).isEqualTo(ID);
assertThat(decoded.getIdReference()).isEqualTo(ID);
assertThat(decoded.getUser().getIdReference()).isEqualTo("alice");
assertThat(decoded.getPermissions()).containsExactly("p.read");
assertThat(decoded.getValue("tenant")).isEqualTo("t-1");
}
/***************************************************************************
** A session whose id reference differs from its uuid keeps its own value.
***************************************************************************/
@Test
void testDifferingIdReferenceIsKept() throws IOException
{
QSession session = new QSession();
session.setUuid(ID);
session.setIdReference("alice@example.com");
QSession decoded = SessionCodec.decode(ID, SessionCodec.encode(ID, session));
assertThat(decoded.getUuid()).isEqualTo(ID);
assertThat(decoded.getIdReference()).isEqualTo("alice@example.com");
}
/***************************************************************************
** An envelope read under another id, a legacy plain session, and junk are all refused.
***************************************************************************/
@Test
void testForeignLegacyAndJunkAreRefused()
{
String stored = SessionCodec.encode(ID, new QSession().withValue("k", "v"));
assertThatThrownBy(() -> SessionCodec.decode(ID + "other", stored)).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, JsonUtils.toJson(new QSession().withValue("k", "v")))).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, "not json")).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, stored.replace("\"v\":1", "\"v\":2"))).isInstanceOf(IOException.class);
}
}
@@ -0,0 +1,238 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.
*/
package com.kingsrook.qbits.sessionstore.providers;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.github.dockerjava.api.command.InspectContainerResponse;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
/*******************************************************************************
** Where a disposable test container is reachable FROM THIS JVM.
**
** The original behavior is Testcontainers' own: the host it reports and the
** published port. That is kept everywhere except one measured topology: a JVM
** inside a build container that shares the daemon's default bridge with the
** fixture (the full Linux verify, which talks to the host daemon through its
** socket). On the Docker Desktop runner the published port is reached through
** the host-gateway hop; measured on an idle machine that path showed round trips
** of about 1 ms with stalls of 0.3 s and about 2 s, against about 0.13 ms and
** no stalls when dialing the sibling directly on the bridge. These tests use
** 250 ms to 1 s socket timeouts, so such stalls can expire them; that is a
** plausible contributor, not shown to be the only cause of the observed
** "Read timed out" failures. In that one topology the fixture is dialed on its
** bridge address and its own port.
**
** The choice is made from the daemon's own description of the build container's
** networks (no connection probing). If the build container cannot be identified
** or is not on the default bridge (a job network, host networking, a remote
** daemon), the original published endpoint is used unchanged. Timeouts,
** scenarios, assertions and server configuration are unchanged.
*******************************************************************************/
final class FixtureEndpoint
{
private static final String DEFAULT_BRIDGE = "bridge";
private static final Pattern MOUNTINFO_CONTAINER_ID = Pattern.compile("/docker/containers/([0-9a-f]{64})/");
private static volatile Boolean buildContainerOnDefaultBridge;
/***************************************************************************
** Host and port to dial.
***************************************************************************/
record Address(String host, int port)
{
}
/***************************************************************************
** Utility class.
***************************************************************************/
private FixtureEndpoint()
{
}
/***************************************************************************
** Host this JVM must dial for the container.
***************************************************************************/
static String host(GenericContainer<?> container)
{
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).host());
}
/***************************************************************************
** Port matching {@link #host(GenericContainer)}.
***************************************************************************/
static int port(GenericContainer<?> container)
{
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).port());
}
/***************************************************************************
** A container that lives only on a custom test network is also attached to the
** default bridge when (and only when) the build container is on that bridge, so
** that the build container can reach it directly. The test network, and therefore
** every partition scenario between the fixture servers, is untouched: attach only
** the container the test client dials, never one whose reachability the test cuts
** or whose peers must stay isolated from it. No-op in every other topology.
***************************************************************************/
static void attachToDefaultBridgeWhenInsideContainer(GenericContainer<?> container)
{
if(!onBridgeWithBuildContainer())
{
return;
}
var docker = container.getDockerClient();
var networks = docker.inspectContainerCmd(container.getContainerId()).exec().getNetworkSettings().getNetworks();
if(networks == null || !networks.containsKey(DEFAULT_BRIDGE))
{
docker.connectToNetworkCmd().withContainerId(container.getContainerId()).withNetworkId(DEFAULT_BRIDGE).exec();
}
}
/***************************************************************************
** Whether this JVM runs in a container that the daemon reports on the default
** bridge. Decided once per JVM from the daemon's description of this container.
***************************************************************************/
private static boolean onBridgeWithBuildContainer()
{
Boolean known = buildContainerOnDefaultBridge;
if(known == null)
{
known = decideBuildContainerOnDefaultBridge();
buildContainerOnDefaultBridge = known;
}
return (known);
}
/***************************************************************************
** Inspect the build container; anything that cannot be established means the
** original published endpoint (false).
***************************************************************************/
private static boolean decideBuildContainerOnDefaultBridge()
{
if(!Files.exists(Path.of("/.dockerenv")))
{
return (false);
}
try
{
String id = ownContainerId(Files.readString(Path.of("/proc/self/mountinfo")), System.getenv("HOSTNAME"));
if(id == null)
{
return (false);
}
var networks = DockerClientFactory.instance().client().inspectContainerCmd(id).exec().getNetworkSettings().getNetworks();
return (networks != null && networks.containsKey(DEFAULT_BRIDGE));
}
catch(Exception e)
{
return (false);
}
}
/***************************************************************************
** The id of the container this JVM runs in: from the mount table when it names
** the container directory, otherwise the host name (the container id unless
** overridden); null when neither exists.
***************************************************************************/
static String ownContainerId(String mountInfo, String hostName)
{
Matcher matcher = MOUNTINFO_CONTAINER_ID.matcher(mountInfo == null ? "" : mountInfo);
if(matcher.find())
{
return (matcher.group(1));
}
return (hostName == null || hostName.isBlank() ? null : hostName.trim());
}
/***************************************************************************
** The container's address on the default bridge, or null when it has none.
***************************************************************************/
private static String bridgeIp(GenericContainer<?> container)
{
if(!onBridgeWithBuildContainer())
{
return (null);
}
InspectContainerResponse inspected = container.getDockerClient().inspectContainerCmd(container.getContainerId()).exec();
var networks = inspected.getNetworkSettings().getNetworks();
return (networks == null || networks.get(DEFAULT_BRIDGE) == null ? null : networks.get(DEFAULT_BRIDGE).getIpAddress());
}
/***************************************************************************
** Testcontainers' published port for the Redis port, or null when the build
** container dials the bridge (so the published port is not consulted).
***************************************************************************/
private static Integer publishedPort(GenericContainer<?> container)
{
return (onBridgeWithBuildContainer() ? null : container.getMappedPort(6379));
}
/***************************************************************************
** Pure decision, separately tested. With the build container on the default
** bridge: the sibling's bridge address and its own port. Otherwise the published
** endpoint exactly as Testcontainers reports it.
***************************************************************************/
static Address choose(boolean buildContainerOnDefaultBridge, String bridgeIp, int internalPort, String publishedHost, Integer publishedPort)
{
if(buildContainerOnDefaultBridge)
{
if(bridgeIp == null || bridgeIp.isBlank())
{
throw new IllegalStateException("FIXTURE_ENDPOINT_NO_BRIDGE_ADDRESS: the build container cannot reach a sibling without its bridge address");
}
return (new Address(bridgeIp, internalPort));
}
if(publishedHost == null || publishedHost.isBlank() || publishedPort == null)
{
throw new IllegalStateException("FIXTURE_ENDPOINT_NOT_PUBLISHED: no published endpoint to dial");
}
return (new Address(publishedHost, publishedPort));
}
}
@@ -0,0 +1,92 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.
*/
package com.kingsrook.qbits.sessionstore.providers;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/*******************************************************************************
** The topology decision of the sibling-container fixtures.
*******************************************************************************/
class FixtureEndpointTest
{
private static final String CONTAINER_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/***************************************************************************
** Build container on the default bridge: dial the sibling bridge IP and its own port, never the published port.
***************************************************************************/
@Test
void buildContainerOnTheBridgeUsesTheBridgeAddress()
{
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, "localhost", 41111));
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, null, null));
}
/***************************************************************************
** Every other topology (JVM on the Docker host, remote daemon, job network, host networking, unidentified
** build container) keeps the original Testcontainers host and published port exactly, whatever the host is.
***************************************************************************/
@Test
void everyOtherTopologyKeepsTheOriginalPublishedEndpoint()
{
assertEquals(new FixtureEndpoint.Address("localhost", 41111), FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", 41111));
assertEquals(new FixtureEndpoint.Address("docker.example.test", 32768), FixtureEndpoint.choose(false, null, 6379, "docker.example.test", 32768));
assertEquals(new FixtureEndpoint.Address("host.docker.internal", 40000), FixtureEndpoint.choose(false, null, 6379, "host.docker.internal", 40000));
}
/***************************************************************************
** An unknown endpoint is an error, never a guessed address.
***************************************************************************/
@Test
void missingEndpointFailsClosed()
{
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, null, 6379, "localhost", 41111));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, " ", 6379, "localhost", 41111));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", null));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, " ", 41111));
}
/***************************************************************************
** The build container id comes from the mount table when present, else the host name.
***************************************************************************/
@Test
void ownContainerIdPrefersTheMountTableThenTheHostName()
{
String mountInfo = "700 699 0:50 /docker/containers/" + CONTAINER_ID + "/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw";
assertEquals(CONTAINER_ID, FixtureEndpoint.ownContainerId(mountInfo, "unrelated"));
assertEquals("abc123", FixtureEndpoint.ownContainerId("no container mounts here", " abc123 "));
assertNull(FixtureEndpoint.ownContainerId("", null));
assertNull(FixtureEndpoint.ownContainerId(null, " "));
}
}
@@ -28,6 +28,7 @@ import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitContext;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreRuntimeService;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -60,6 +61,8 @@ class SharedRedisSessionStoreProviderIT
{
primary.start();
replica.start();
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(primary);
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(replica);
QInstance first = host(primary, "promotion", true);
QInstance second = host(replica, "promotion", false);
QInstance third = host(replica, "promotion", true);
@@ -119,7 +122,7 @@ class SharedRedisSessionStoreProviderIT
assertEquals("carol", c.loadAndTouch("new").orElseThrow().getValue("owner"));
var namespace = new RedisNamespace("test", "promotion", "authentication", "session-store");
var client = RedisRuntimeService.connection(second, "sessions").client();
assertTrue(client.pttl(namespace.key("new")) > 20000);
assertTrue(client.pttl(namespace.key(SessionCodec.key("new"))) > 20000);
c.remove("active");
assertTrue(b.load("active").isEmpty());
b.store("expires", new QSession(), Duration.ofMillis(200));
@@ -192,10 +195,11 @@ class SharedRedisSessionStoreProviderIT
var client = RedisRuntimeService.connection(first, "sessions").client();
a.store("sliding", new QSession(), Duration.ofSeconds(1));
assertTrue(a.loadAndTouch("sliding").isPresent());
assertTrue(client.pttl(scope.key("sliding")) > 20000);
assertTrue(client.pttl(scope.key(SessionCodec.key("sliding"))) > 20000);
assertEquals(-2L, client.pttl(scope.key("sliding")), "the raw session id must never be a Redis key");
a.store("fixed", new QSession(), Duration.ofMillis(200));
assertTrue(b.loadAndTouch("fixed").isPresent());
assertTrue(client.pttl(scope.key("fixed")) <= 200);
assertTrue(client.pttl(scope.key(SessionCodec.key("fixed"))) <= 200);
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
while(b.load("fixed").isPresent() && System.nanoTime() < deadline)
{
@@ -207,7 +211,7 @@ class SharedRedisSessionStoreProviderIT
assertThrows(IllegalArgumentException.class,
() -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null));
RedisAccess.cache(first, "sessions", scope, 65536)
.put("broken", "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
.put(SessionCodec.key("broken"), "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
var malformed = assertThrows(IllegalStateException.class, () -> a.load("broken"));
assertFalse(malformed.toString().contains("test-secret"));
assertNull(malformed.getCause());
@@ -533,7 +537,7 @@ class SharedRedisSessionStoreProviderIT
instance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("record").withBackendName("memory").withPrimaryKeyField("id")
.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER)));
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("sessions",
new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379))
new RedisConnectionConfig().withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
.withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300)))
.produce(instance);
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
@@ -0,0 +1,491 @@
/*
* Copyright 2024 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
*
* http://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.
*/
package com.kingsrook.qbits.sessionstore.providers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderFactory;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.StoredSessionSchema;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
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.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The TABLE_BASED provider on a real PostgreSQL 17, with the production
** separation of identities: a schema OWNER applies the DDL once, and the
** provider runs as a DML-only role that owns nothing and cannot create or alter
** anything. Two application instances (separate QInstances and providers)
** share the one database, as replicas of the host would.
*******************************************************************************/
class TableBasedSessionStorePostgresIT
{
private static final String OWNER = "qbit_owner";
private static final String RUNTIME = "qbit_runtime";
private static final String BACKEND = "sessionsPg";
private static final String TABLE = "qbitSessionStoreEntry";
private static GenericContainer<?> postgres;
private static String host;
private static int port;
private QSessionStoreQBitConfig config;
private QInstance instanceA;
private QInstance instanceB;
private QSessionStoreProviderInterface providerA;
private QSessionStoreProviderInterface providerB;
/***************************************************************************
** One PG17, owner-created schema, DML-only runtime role.
***************************************************************************/
@BeforeAll
static void startDatabase() throws Exception
{
postgres = new GenericContainer<>("postgres:17-alpine")
.withEnv("POSTGRES_PASSWORD", "super-synthetic")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2).withStartupTimeout(Duration.ofMinutes(2)));
postgres.start();
host = postgres.getHost();
port = postgres.getMappedPort(5432);
try(Connection admin = connect("postgres", "super-synthetic", "postgres"); Statement statement = admin.createStatement())
{
statement.execute("CREATE ROLE " + OWNER + " LOGIN PASSWORD 'owner-synthetic'");
statement.execute("CREATE ROLE " + RUNTIME + " LOGIN PASSWORD 'runtime-synthetic'");
statement.execute("CREATE DATABASE sessions OWNER " + OWNER);
statement.execute("REVOKE ALL ON DATABASE sessions FROM PUBLIC");
statement.execute("GRANT CONNECT ON DATABASE sessions TO " + RUNTIME);
}
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC");
statement.execute("GRANT USAGE ON SCHEMA public TO " + RUNTIME);
String ddl = StoredSessionSchema.postgresDdl(configFor());
statement.execute(ddl);
statement.execute(ddl); // idempotent
String table = StoredSessionSchema.physicalTableName(configFor());
statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON " + StoredSessionSchema.quote(table) + " TO " + RUNTIME);
}
}
/***************************************************************************
** Stop the database.
***************************************************************************/
@AfterAll
static void stopDatabase()
{
if(postgres != null)
{
postgres.stop();
}
}
/***************************************************************************
** Fresh table content and two runtime instances per test.
***************************************************************************/
@BeforeEach
void setUp() throws Exception
{
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("DELETE FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(configFor())));
}
config = configFor();
instanceA = instance();
instanceB = instance();
QContext.init(instanceA, new QSession());
providerA = QSessionStoreProviderFactory.createProvider(instanceA, config);
QContext.init(instanceB, new QSession());
providerB = QSessionStoreProviderFactory.createProvider(instanceB, config);
}
/***************************************************************************
** Clear the context.
***************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/***************************************************************************
** Store and load as the DML-only role; a second instance sees it, and a
** removal on either instance revokes it on both.
***************************************************************************/
@Test
void testSharedAcrossInstancesAndRevocation()
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
QSession seen = on(instanceB, () -> providerB.load(id)).orElseThrow();
assertThat(seen.getUuid()).isEqualTo(id);
assertThat(seen.getUser().getIdReference()).isEqualTo("alice");
assertThat(seen.getValue("tenant")).isEqualTo("tenant-a");
assertThat(seen.getPermissions()).containsExactly("orders.read");
run(instanceB, () -> providerB.remove(id));
assertThat(on(instanceA, () -> providerA.load(id))).isEmpty();
assertThat(on(instanceA, () -> providerA.loadAndTouch(id))).isEmpty();
assertThat(rows()).isEmpty();
}
/***************************************************************************
** Re-storing a session replaces what was cached: narrower permissions or a
** different tenant never survive as a stale copy; one row remains.
***************************************************************************/
@Test
void testRestoreReplacesStaleCopy()
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.write", "orders.read"), Duration.ofMinutes(10)));
run(instanceB, () -> providerB.store(id, session(id, "alice", "tenant-b", "orders.read"), Duration.ofMinutes(10)));
QSession seen = on(instanceA, () -> providerA.load(id)).orElseThrow();
assertThat(seen.getValue("tenant")).isEqualTo("tenant-b");
assertThat(seen.getPermissions()).containsExactly("orders.read");
assertThat(rows()).hasSize(1);
}
/***************************************************************************
** Sessions of different actors and tenants stay separate, and a session id
** that was never stored loads nothing.
***************************************************************************/
@Test
void testActorAndTenantSeparation()
{
String idAlice = UUID.randomUUID().toString();
String idBob = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(idAlice, session(idAlice, "alice", "tenant-a", "a.read"), Duration.ofMinutes(10)));
run(instanceA, () -> providerA.store(idBob, session(idBob, "bob", "tenant-b", "b.read"), Duration.ofMinutes(10)));
QSession alice = on(instanceB, () -> providerB.load(idAlice)).orElseThrow();
QSession bob = on(instanceB, () -> providerB.load(idBob)).orElseThrow();
assertThat(alice.getUser().getIdReference()).isEqualTo("alice");
assertThat(alice.getValue("tenant")).isEqualTo("tenant-a");
assertThat(bob.getUser().getIdReference()).isEqualTo("bob");
assertThat(bob.getValue("tenant")).isEqualTo("tenant-b");
assertThat(on(instanceB, () -> providerB.load(UUID.randomUUID().toString()))).isEmpty();
assertThat(on(instanceB, () -> providerB.load(idAlice.toUpperCase()))).isEmpty();
}
/***************************************************************************
** The database holds no session id and no id-bearing JSON: only digests.
***************************************************************************/
@Test
void testDatabaseHoldsNoSessionId() throws Exception
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
List<String> columns = StoredSessionSchema.physicalColumnNames(config);
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement();
ResultSet rs = statement.executeQuery("SELECT * FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config))))
{
assertThat(rs.next()).isTrue();
for(String column : columns)
{
String value = rs.getString(column);
assertThat(value == null ? "" : value).as(column).doesNotContain(id);
}
assertThat(rs.getString("sessionUuid")).isEqualTo(SessionCodec.key(id));
}
}
/***************************************************************************
** An expired row is a miss and is removed; cleanup deletes only expired rows.
***************************************************************************/
@Test
void testExpiryAndCleanup() throws Exception
{
String live = UUID.randomUUID().toString();
String expired = UUID.randomUUID().toString();
String lazy = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(live, session(live, "alice", "t", "p"), Duration.ofMinutes(10)));
run(instanceA, () -> providerA.store(expired, session(expired, "bob", "t", "p"), Duration.ofMillis(1)));
run(instanceA, () -> providerA.store(lazy, session(lazy, "carol", "t", "p"), Duration.ofMillis(1)));
Thread.sleep(50);
assertThat(on(instanceB, () -> providerB.load(lazy))).isEmpty();
assertThat(rows()).hasSize(2);
run(instanceB, providerB::cleanExpired);
assertThat(rows()).hasSize(1);
assertThat(on(instanceA, () -> providerA.load(live))).isPresent();
assertThat(on(instanceA, () -> providerA.load(expired))).isEmpty();
}
/***************************************************************************
** A row whose identity column no longer matches its envelope is discarded
** rather than served.
***************************************************************************/
@Test
void testTamperedRowIsDiscarded() throws Exception
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("UPDATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " SET \"userId\" = 'mallory'");
}
assertThat(on(instanceB, () -> providerB.load(id))).isEmpty();
assertThat(rows()).isEmpty();
}
/***************************************************************************
** The runtime identity can do DML only: no DDL, no other relation, and the
** schema it was given is not its own.
***************************************************************************/
@Test
void testRuntimeRoleHasNoDdlOrOwnership() throws Exception
{
try(Connection runtime = connect(RUNTIME, "runtime-synthetic", "sessions"); Statement statement = runtime.createStatement())
{
assertThatThrownBy(() -> statement.execute("CREATE TABLE intruder (id int)")).isInstanceOf(SQLException.class).hasMessageContaining("permission denied");
assertThatThrownBy(() -> statement.execute("ALTER TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " ADD COLUMN x int")).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("DROP TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("TRUNCATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class);
try(ResultSet rs = statement.executeQuery("SELECT count(*) FROM pg_class c JOIN pg_roles r ON r.oid = c.relowner WHERE r.rolname = '" + RUNTIME + "'"))
{
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
}
/***************************************************************************
** The table is internal: hidden, and a signed-in user session, however
** privileged elsewhere, cannot query it through the core actions.
***************************************************************************/
@Test
void testTableIsInternal() throws Exception
{
QSessionStoreQBitConfig produced = configFor();
QInstance instance = baseInstance();
new QSessionStoreQBitProducer().withConfig(produced).produce(instance, "");
new QInstanceValidator().validate(instance);
assertThat(instance.getTable(TABLE).getIsHidden()).isTrue();
QSession user = new QSession();
user.setUser(new QUser().withIdReference("alice"));
user.setPermissions(Set.of("everything.read"));
QContext.init(instance, user);
assertThatThrownBy(() ->
{
QueryInput input = new QueryInput();
input.setTableName(TABLE);
new QueryAction().execute(input);
}).isInstanceOf(QException.class);
}
/***************************************************************************
** DDL is derived from the producer's metadata, idempotent, and names every
** field.
***************************************************************************/
@Test
void testDdlMatchesMetadata()
{
String ddl = StoredSessionSchema.postgresDdl(configFor());
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS \"qbitSessionStoreEntry\"").contains("\"sessionUuid\" VARCHAR(64) NOT NULL")
.contains("GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY").contains("CREATE INDEX IF NOT EXISTS");
assertThat(StoredSessionSchema.physicalColumnNames(configFor())).containsExactlyInAnyOrder("id", "sessionUuid", "userId", "sessionData", "expiresAt", "createDate", "modifyDate");
String snake = StoredSessionSchema.postgresDdl(configFor(), com.kingsrook.qqq.backend.core.instances.QInstanceEnricher::inferBackendName);
assertThat(snake).contains("CREATE TABLE IF NOT EXISTS \"qbit_session_store_entry\"").contains("\"session_uuid\" VARCHAR(64) NOT NULL").contains("\"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL");
}
/***************************************************************************
** Run something with the given instance as the QQQ context, as one
** application replica would.
***************************************************************************/
private static <T> T on(QInstance instance, java.util.function.Supplier<T> action)
{
QContext.init(instance, new QSession());
return action.get();
}
/***************************************************************************
** Void variant.
***************************************************************************/
private static void run(QInstance instance, Runnable action)
{
QContext.init(instance, new QSession());
action.run();
}
/***************************************************************************
** The row count, read as the owner.
***************************************************************************/
private List<String> rows()
{
List<String> keys = new ArrayList<>();
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement();
ResultSet rs = statement.executeQuery("SELECT \"sessionUuid\" FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config))))
{
while(rs.next())
{
keys.add(rs.getString(1));
}
}
catch(SQLException e)
{
throw new IllegalStateException(e);
}
return keys;
}
/***************************************************************************
** A synthetic session.
***************************************************************************/
private static QSession session(String id, String user, String tenant, String... permissions)
{
QSession session = new QSession();
session.setUuid(id);
session.setIdReference(id);
session.setUser(new QUser().withIdReference(user));
session.setPermissions(Set.of(permissions));
session.setValue("tenant", tenant);
return session;
}
/***************************************************************************
** The provider configuration.
***************************************************************************/
private static QSessionStoreQBitConfig configFor()
{
return new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.TABLE_BASED)
.withBackendName(BACKEND)
.withTableName(TABLE)
.withDefaultTtl(Duration.ofMinutes(30))
.withEnableCleanupProcess(false);
}
/***************************************************************************
** A runtime application instance on the DML-only role, enriched and
** validated the way a started server is.
***************************************************************************/
private static QInstance instance() throws QException
{
QInstance instance = baseInstance();
com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer.produce(instance, configFor());
new QInstanceValidator().validate(instance);
return instance;
}
/***************************************************************************
** The application without the qbit: enforcement on, native PG backend as the
** DML-only role.
***************************************************************************/
private static QInstance baseInstance()
{
QInstance instance = new QInstance();
instance.setEnforcePermissionsInCoreActions(true);
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData();
backend.setName(BACKEND);
backend.setHostName(host);
backend.setPort(port);
backend.setDatabaseName("sessions");
backend.setUsername(RUNTIME);
backend.setPassword("runtime-synthetic");
instance.addBackend(backend);
return instance;
}
/***************************************************************************
** A JDBC connection as a named role.
***************************************************************************/
private static Connection connect(String user, String password, String database) throws SQLException
{
return DriverManager.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database, user, password);
}
}
@@ -110,7 +110,7 @@ class TableBasedSessionStoreProviderIT
conn.createStatement().execute("""
CREATE TABLE `storedSession` (
`id` INTEGER AUTO_INCREMENT PRIMARY KEY,
`sessionUuid` VARCHAR(36) NOT NULL UNIQUE,
`sessionUuid` VARCHAR(64) NOT NULL UNIQUE,
`userId` VARCHAR(255),
`sessionData` TEXT,
`expiresAt` TIMESTAMP NOT NULL,