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
19 changed files with 1724 additions and 208 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)
+22 -7
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>
@@ -137,7 +149,7 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
@@ -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;
@@ -45,6 +46,115 @@ import static org.junit.jupiter.api.Assertions.*;
*******************************************************************************/
class SharedRedisSessionStoreProviderIT
{
/***************************************************************************
** Observed replicated sessions and revocations survive controlled promotion.
** This does not claim survival of writes lost before asynchronous replication.
***************************************************************************/
@Test
void replicatedSessionsAndRevocationsSurviveControlledPromotion() throws Exception
{
QInstance original = QContext.getQInstance();
try(var network = org.testcontainers.containers.Network.newNetwork();
var primary = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withNetworkAliases("primary").withExposedPorts(6379);
var replica = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withExposedPorts(6379)
.withCommand("redis-server", "--replicaof", "primary", "6379"))
{
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);
var firstRuntime = new RedisRuntimeService();
var secondRuntime = new RedisRuntimeService();
var thirdRuntime = new RedisRuntimeService();
try
{
firstRuntime.start(first);
secondRuntime.start(second);
thirdRuntime.start(third);
var a = QSessionStoreQBitContext.getProvider(first);
var b = QSessionStoreQBitContext.getProvider(second);
var c = QSessionStoreQBitContext.getProvider(third);
a.store("active", new QSession().withValue("owner", "alice"), Duration.ofSeconds(60));
a.store("revoked", new QSession().withValue("owner", "bob"), Duration.ofSeconds(60));
assertTimeoutPreemptively(Duration.ofSeconds(15), () ->
{
while(true)
{
try
{
if(b.load("revoked").isPresent())
{
break;
}
}
catch(redis.clients.jedis.exceptions.JedisConnectionException initialSync)
{
// Initial replica loading can temporarily block reads; the outer deadline stays bounded.
}
Thread.sleep(25);
}
});
assertThrows(RuntimeException.class, () -> b.store("replica-write", new QSession(), null));
a.remove("revoked");
// Seeing a later write proves this test's deletion reached the replica.
a.store("barrier", new QSession(), Duration.ofSeconds(60));
assertTimeoutPreemptively(Duration.ofSeconds(5), () ->
{
while(b.load("barrier").isEmpty())
{
Thread.sleep(25);
}
});
assertTrue(b.load("revoked").isEmpty());
primary.stop();
assertThrows(RuntimeException.class, () -> a.load("active"));
var promoted = replica.execInContainer("redis-cli", "REPLICAOF", "NO", "ONE");
assertEquals(0, promoted.getExitCode());
assertEquals("OK", promoted.getStdout().trim());
assertEquals("alice", b.load("active").orElseThrow().getValue("owner"));
assertTrue(c.loadAndTouch("revoked").isEmpty());
c.touch("revoked");
assertTrue(b.load("revoked").isEmpty());
b.store("new", new QSession().withValue("owner", "carol"), Duration.ofSeconds(1));
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(SessionCodec.key("new"))) > 20000);
c.remove("active");
assertTrue(b.load("active").isEmpty());
b.store("expires", new QSession(), Duration.ofMillis(200));
assertTimeoutPreemptively(Duration.ofSeconds(3), () ->
{
while(b.load("expires").isPresent())
{
Thread.sleep(25);
}
});
c.touch("expires");
assertTrue(c.loadAndTouch("expires").isEmpty());
}
finally
{
for(var runtime : List.of(firstRuntime, secondRuntime, thirdRuntime))
{
runtime.stop();
runtime.afterApplicationStop();
}
for(var instance : List.of(first, second, third))
{
QSessionStoreQBitContext.clear(instance);
QSessionStoreRegistry.getInstance(instance).clear();
}
QContext.setQInstance(original);
}
}
}
/***************************************************************************
** Separate nodes share one app's sessions while other apps remain isolated.
***************************************************************************/
@@ -85,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)
{
@@ -100,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());
@@ -426,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,