From 9f3cc20c59795d4622cd95d44373410e59ce7cc4 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 20:03:59 +0100 Subject: [PATCH 1/3] Add Quarkus document revisions with immutable snapshots and versioned publication --- .github/workflows/document-revisions.yml | 28 +++ applications/document-revisions/.env.example | 11 + applications/document-revisions/.gitignore | 4 + applications/document-revisions/README.md | 190 ++++++++++++++++++ .../checks/cloud-controls.py | 133 ++++++++++++ .../document-revisions/checks/jdbc-url.py | 10 + .../document-revisions/checks/launch.py | 8 + .../checks/security-restart.py | 82 ++++++++ applications/document-revisions/pom.xml | 105 ++++++++++ .../document-revisions/sql/bootstrap.sql | 18 ++ applications/document-revisions/sql/seed.sql | 5 + .../src/main/java/example/BearerAuth.java | 42 ++++ .../src/main/java/example/Document.java | 18 ++ .../main/java/example/DocumentsResource.java | 44 ++++ .../src/main/java/example/Dtos.java | 25 +++ .../src/main/java/example/Inputs.java | 41 ++++ .../src/main/java/example/JdbcUrl.java | 19 ++ .../src/main/java/example/Launch.java | 12 ++ .../src/main/java/example/LiveResource.java | 11 + .../src/main/java/example/Migrate.java | 28 +++ .../src/main/java/example/PublicErrors.java | 28 +++ .../src/main/java/example/Revision.java | 19 ++ .../src/main/java/example/RevisionKey.java | 20 ++ .../src/main/java/example/StrictJson.java | 25 +++ .../src/main/java/example/Workflow.java | 120 +++++++++++ .../src/main/resources/application.properties | 26 +++ .../resources/db/migration/V1__documents.sql | 38 ++++ .../src/test/java/example/InputsTest.java | 28 +++ .../src/test/java/example/JdbcUrlTest.java | 15 ++ 29 files changed, 1153 insertions(+) create mode 100644 .github/workflows/document-revisions.yml create mode 100644 applications/document-revisions/.env.example create mode 100644 applications/document-revisions/.gitignore create mode 100644 applications/document-revisions/README.md create mode 100755 applications/document-revisions/checks/cloud-controls.py create mode 100755 applications/document-revisions/checks/jdbc-url.py create mode 100755 applications/document-revisions/checks/launch.py create mode 100755 applications/document-revisions/checks/security-restart.py create mode 100644 applications/document-revisions/pom.xml create mode 100644 applications/document-revisions/sql/bootstrap.sql create mode 100644 applications/document-revisions/sql/seed.sql create mode 100644 applications/document-revisions/src/main/java/example/BearerAuth.java create mode 100644 applications/document-revisions/src/main/java/example/Document.java create mode 100644 applications/document-revisions/src/main/java/example/DocumentsResource.java create mode 100644 applications/document-revisions/src/main/java/example/Dtos.java create mode 100644 applications/document-revisions/src/main/java/example/Inputs.java create mode 100644 applications/document-revisions/src/main/java/example/JdbcUrl.java create mode 100644 applications/document-revisions/src/main/java/example/Launch.java create mode 100644 applications/document-revisions/src/main/java/example/LiveResource.java create mode 100644 applications/document-revisions/src/main/java/example/Migrate.java create mode 100644 applications/document-revisions/src/main/java/example/PublicErrors.java create mode 100644 applications/document-revisions/src/main/java/example/Revision.java create mode 100644 applications/document-revisions/src/main/java/example/RevisionKey.java create mode 100644 applications/document-revisions/src/main/java/example/StrictJson.java create mode 100644 applications/document-revisions/src/main/java/example/Workflow.java create mode 100644 applications/document-revisions/src/main/resources/application.properties create mode 100644 applications/document-revisions/src/main/resources/db/migration/V1__documents.sql create mode 100644 applications/document-revisions/src/test/java/example/InputsTest.java create mode 100644 applications/document-revisions/src/test/java/example/JdbcUrlTest.java diff --git a/.github/workflows/document-revisions.yml b/.github/workflows/document-revisions.yml new file mode 100644 index 00000000..3a56d515 --- /dev/null +++ b/.github/workflows/document-revisions.yml @@ -0,0 +1,28 @@ +name: Document revisions +on: + push: + paths: ['applications/document-revisions/**', '.github/workflows/document-revisions.yml'] + pull_request: + paths: ['applications/document-revisions/**', '.github/workflows/document-revisions.yml'] +permissions: + contents: read +jobs: + jvm: + runs-on: ubuntu-24.04-arm + defaults: + run: + working-directory: applications/document-revisions + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-java@v6.0.1 + with: + distribution: temurin + java-version: '21' + - name: Install verified Maven 3.10.0 + run: | + curl -fsSLo "$RUNNER_TEMP/maven.tar.gz" https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.tar.gz + echo "908b1501bfb420bf7c8affb855534a9c407fd6099367bfb9f2f2dcb8e9799102bffb84518cde74c679bd76870247c6528683abdd620581bffa90f95d92d175aa $RUNNER_TEMP/maven.tar.gz" | sha512sum --check + tar -xzf "$RUNNER_TEMP/maven.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/apache-maven-3.10.0/bin" >> "$GITHUB_PATH" + - name: Boundary tests and production Quarkus package without database credentials + run: mvn -B -ntp clean package diff --git a/applications/document-revisions/.env.example b/applications/document-revisions/.env.example new file mode 100644 index 00000000..b500d681 --- /dev/null +++ b/applications/document-revisions/.env.example @@ -0,0 +1,11 @@ +# Endpoint fields are used by checks/jdbc-url.py; no secrets in a JDBC URL. +PGHOST=YOUR_CLOUD_POSTGRES_HOST +PGPORT=5432 +PGDATABASE=postgres +PGSSLROOTCERT=/absolute/path/to/ca.pem +PGSSLMODE=verify-full +PGUSER=revision_app +PGPASSWORD=YOUR_RUNTIME_PASSWORD +JDBC_URL=jdbc:postgresql://YOUR_CLOUD_POSTGRES_HOST:5432/postgres +ACCOUNT_001_TOKEN=YOUR_64_LOWERCASE_HEX_TOKEN +ACCOUNT_002_TOKEN=YOUR_DISTINCT_64_LOWERCASE_HEX_TOKEN diff --git a/applications/document-revisions/.gitignore b/applications/document-revisions/.gitignore new file mode 100644 index 00000000..c6a694a2 --- /dev/null +++ b/applications/document-revisions/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.deployment/ +/.env +__pycache__/ diff --git a/applications/document-revisions/README.md b/applications/document-revisions/README.md new file mode 100644 index 00000000..c546f38c --- /dev/null +++ b/applications/document-revisions/README.md @@ -0,0 +1,190 @@ +# Document revisions with Quarkus and Panache + +A JSON API for account-owned documents on ClickHouse Managed Postgres (public beta). Editing appends an immutable text snapshot and advances a draft pointer. Publishing independently selects an existing revision. New drafts leave published content unchanged. + +Quarkus REST handles HTTP, native Quarkus security maps two seeded accounts to bearer tokens, Hibernate ORM Panache supplies scoped queries and row locks, and Flyway owns schema changes. The application runs on the JVM. It has no frontend, arbitrary HTML, external storage or real deployment integration. + +The verified stack is Java 21, Maven 3.10.0, Quarkus 3.40.1 (3.40 LTS), Hibernate ORM 7.4.9.Final, Flyway 12.0.0 and pgJDBC 42.7.13. The Quarkus BOM pins the library graph; explicit build plugin versions live in `pom.xml`. Live acceptance used PostgreSQL 18.6 in ClickHouse Cloud. + +## Native build + +Use a Linux machine with JDK 21 and Maven 3.10.0. On Ubuntu, install the utilities first: + +```bash +sudo apt-get update +sudo apt-get install -y openjdk-21-jdk-headless curl ca-certificates python3 postgresql-client jq openssl +``` + +Install [Apache Maven 3.10.0](https://maven.apache.org/download.cgi) from its official release archive, checking the published SHA-512. The tested release checksum was: + +```text +908b1501bfb420bf7c8affb855534a9c407fd6099367bfb9f2f2dcb8e9799102bffb84518cde74c679bd76870247c6528683abdd620581bffa90f95d92d175aa +``` + +From this application directory: + +```bash +mvn -B -ntp clean package +``` + +This runs input and JDBC endpoint boundary tests and produces `target/quarkus-app/quarkus-run.jar`. It requires no database or Cloud credentials. Dev Services, ORM schema generation, SQL auto-loading and Flyway startup migrations are disabled. There is no container-backed substitute for Cloud acceptance. + +## Create an isolated Cloud database + +Install [clickhousectl](https://clickhouse.com/docs/cloud/manage/cli), authenticate with your own Cloud API key, and select your organization. These commands use clickhousectl 0.5.0. The example creates a billable AWS service without HA; choose a supported size and region for your organization. + +```bash +umask 077 +mkdir -p "$HOME/document-private" +clickhousectl cloud postgres create --org-id YOUR_ORG_ID \ + --name document-revisions-example --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none \ + > "$HOME/document-private/create.json" +``` + +The create response contains credentials once. Save its ID as `PG_ID`. Check until its state is `running`, then retrieve the official CA bundle: + +```bash +export PG_ID=YOUR_CREATED_POSTGRES_ID +clickhousectl cloud postgres get "$PG_ID" --org-id YOUR_ORG_ID +clickhousectl cloud postgres certs get "$PG_ID" --org-id YOUR_ORG_ID \ + --output "$HOME/document-private/ca.pem" +``` + +Create a private setup environment using the hostname, username and password from the receipt. The connection port is 5432 and the database is `postgres` for this fixture. Generate independent setup/runtime passwords and two 256-bit account tokens: + +```bash +export PGHOST=YOUR_CLOUD_POSTGRES_HOST PGPORT=5432 PGDATABASE=postgres +export PGUSER=YOUR_CLOUD_ADMIN_USER PGPASSWORD=YOUR_CLOUD_ADMIN_PASSWORD +export PGSSLROOTCERT="$HOME/document-private/ca.pem" PGSSLMODE=verify-full +export PGCONNECT_TIMEOUT=5 +export MIGRATOR_PASSWORD="Aa1$(openssl rand -hex 24)" +export APP_PASSWORD="Aa1$(openssl rand -hex 24)" +export ACCOUNT_001_TOKEN="$(openssl rand -hex 32)" +export ACCOUNT_002_TOKEN="$(openssl rand -hex 32)" +export JDBC_URL="$(python3 checks/jdbc-url.py)" +``` + +Save these values privately before leaving the setup terminal. The JDBC helper validates endpoint fields and percent-encodes the database name. The production entry point and migration command both reject URL userinfo, query properties and fragments: pgJDBC URL properties could otherwise override separately configured TLS settings. Both paths use `verify-full`, the downloaded CA, a 5-second connection timeout and a 15-second socket timeout. + +## Roles, migrations and seed + +Run the bootstrap with Cloud administrator credentials: + +```bash +psql -X -v ON_ERROR_STOP=1 \ + -v migrator_password="$MIGRATOR_PASSWORD" -v app_password="$APP_PASSWORD" \ + -f sql/bootstrap.sql +java -Djava.util.logging.manager=org.jboss.logmanager.LogManager \ + -cp 'target/quarkus-app/app/*:target/quarkus-app/lib/main/*:target/quarkus-app/lib/boot/*' \ + example.Migrate +PGUSER=revision_migrator PGPASSWORD="$MIGRATOR_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql +``` + +`revision_owner` cannot log in. The separate Flyway command connects as `revision_migrator`, assumes that owner role and runs versioned SQL without starting HTTP or ORM. Repeating migrations executes zero new migrations; repeating the seed keeps exactly two accounts. Flyway clean is disabled. + +`revision_app` can read accounts, insert documents, update only their title/draft/publication fields, and insert/read revisions. It cannot update or delete existing revision content, change document ownership, create schema objects or assume the owner role. Its sessions have an 8-second statement timeout, 3-second lock timeout and 10-second idle transaction timeout. The Agroal pool admits at most four connections and waits up to three seconds for acquisition. These are separate limits, not an HTTP latency guarantee. + +This is a trusted shared database role: account authorization is enforced by the API, not row-level security. Anyone holding runtime database credentials can bypass the API's version workflow. Protect those credentials. + +## Start the API + +Open a new terminal that has **not** sourced setup credentials. Save/export only the runtime values shown in `.env.example` in a private `app.env`; set `PGUSER=revision_app` and `PGPASSWORD` to `APP_PASSWORD`. The file must contain real 64-character lowercase hex tokens, not the example placeholders. + +```bash +set -a +source "$HOME/document-private/app.env" +set +a +python3 checks/launch.py +``` + +The launcher gives the production JVM an explicit runtime-only environment, excluding administrator and migration credentials. It listens on `127.0.0.1:8080`. `/live` is public process liveness; it is not a database readiness test. Every `/documents` route requires native Quarkus authentication. Serve through an appropriately configured HTTPS gateway before exposing it outside a trusted machine. + +The first token maps to `00000000-0000-0000-0000-000000000001`; the second maps to the account ending in `002`. Tokens are server-managed demo credentials, with no user signup or token rotation endpoint. + +## Try a draft and publication + +In another runtime terminal, load `app.env` with exports as above: + +```bash +curl -sS http://127.0.0.1:8080/documents \ + -H "Authorization: Bearer $ACCOUNT_001_TOKEN" -H 'Content-Type: application/json' \ + -d '{"title":"Release guide","body":"First public text"}' +``` + +Copy the response `id` to `DOCUMENT_ID`. It starts at draft revision 1 with no published selection and publication version 0. + +```bash +export DOCUMENT_ID=YOUR_RETURNED_DOCUMENT_ID +curl -sS "http://127.0.0.1:8080/documents/$DOCUMENT_ID/publication" \ + -H "Authorization: Bearer $ACCOUNT_001_TOKEN" -H 'Content-Type: application/json' \ + -d '{"expectedPublicationVersion":0,"revision":1}' +curl -sS "http://127.0.0.1:8080/documents/$DOCUMENT_ID/revisions" \ + -H "Authorization: Bearer $ACCOUNT_001_TOKEN" -H 'Content-Type: application/json' \ + -d '{"expectedDraftRevision":1,"title":"Release guide","body":"Unpublished changes"}' +curl -sS "http://127.0.0.1:8080/documents/$DOCUMENT_ID/draft" \ + -H "Authorization: Bearer $ACCOUNT_001_TOKEN" +curl -sS "http://127.0.0.1:8080/documents/$DOCUMENT_ID/published" \ + -H "Authorization: Bearer $ACCOUNT_001_TOKEN" +``` + +The draft returns revision 2; the published route still returns revision 1. `GET /documents/{id}/revisions/{number}` selects a historical snapshot. History returns the latest 100 revision summaries in numeric descending order; the document list returns the latest 20 summaries ordered by creation time and UUID. These bounded lists do not provide pagination. DTOs expose content only through the selected content routes. + +## Transaction and retry behavior + +Each edit locks the document using an account-and-document predicate inside an injected CDI `@Transactional` service. It compares the expected draft, inserts and flushes the new snapshot, then advances and flushes the draft pointer. The HTTP resource receives the result only after the transaction interceptor commits. A failed later write or deferred constraint at commit rolls back everything. + +Publishing uses the same scoped lock, checks that the selected revision belongs to this document, then advances a separate publication version. Composite foreign keys enforce same-document targets for both pointers independently of application queries. + +A repeated publication selecting the **current** published revision returns that selection unchanged when its expected version is either the current version or immediately preceding version. This makes an immediate retry after a lost acknowledgment safe; it is not a durable request-ID ledger. Older versions and competing different selections return 409: refetch and decide again. Editing also returns 409 on stale expectations. Create is not idempotent, and a lost create acknowledgment cannot be blindly retried without risking another document. A 503 signals uncertainty; inspect/refetch durable state before choosing a retry. + +Titles are 1–120 UTF-16 code units; bodies are 1–16,000, both nonblank. Bodies permit newline, carriage return and tab; other control characters, NUL and unpaired surrogates are rejected. Revision/version fields are JSON integers between 1 and 1,000,000,000, except publication expectations also allow zero. Unknown fields, duplicate keys, concatenated JSON objects and numeric/string coercions fail. Requests are capped at 64 KiB. Foreign account documents and missing revisions return 404. + +## Cloud acceptance checks + +After starting the production API, use a **separate setup/test terminal**. Export the endpoint/CA/runtime fields from `app.env`, then explicitly export `TEST_MIGRATOR_PASSWORD` from the privately saved migration password. The helpers need the owner role only to install/remove synthetic failure triggers; the running API still has only runtime credentials. + +```bash +export TEST_MIGRATOR_PASSWORD=YOUR_MIGRATION_PASSWORD +export EVIDENCE_DIR="$HOME/document-private" +python3 checks/cloud-controls.py +``` + +The suite performs actual HTTP contention, strict parsing and ownership controls, draft/published separation, repeated publication, PostgreSQL FK/permission failures, an after-insert rollback, and a deferred FK failure at commit. It leaves a synthetic document for restart verification. Save the production JVM PID in `$EVIDENCE_DIR/server.pid` when starting it if you will run the restart helper: + +```bash +python3 checks/launch.py > "$EVIDENCE_DIR/server.log" 2>&1 & +printf '%s\n' "$!" > "$EVIDENCE_DIR/server.pid" +python3 checks/security-restart.py +``` + +Start only one listener; stop an existing foreground listener before the background launch. The restart helper uses the actual packaged application for wrong-CA and wrong-hostname checks, requires specific pgJDBC diagnostics, asserts the original process exited, then reads persisted draft/published state from a different JVM. Private logs contain infrastructure details; keep them out of source control. + +Verified on 2 October 2026: native clean package (three tests), clean Flyway migration/repeat and seed/repeat, real HTTP edit/publication races, rollback/commit failure controls, restricted grants, TLS controls and production process restart. See the companion article's evidence discussion; the article remains a local draft until publication. + +## Cleanup + +Restore the **Cloud administrator** credentials in a setup terminal before optional schema removal; `app.env` selects the restricted runtime role. Schema removal is destructive and only for your disposable fixture: + +```bash +export PGUSER=YOUR_CLOUD_ADMIN_USER PGPASSWORD=YOUR_CLOUD_ADMIN_PASSWORD +psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA revision_api CASCADE;' +``` + +Stop the application and delete only the service you created: + +```bash +clickhousectl cloud postgres delete "$PG_ID" --org-id YOUR_ORG_ID +clickhousectl cloud postgres list --org-id YOUR_ORG_ID +``` + +Wait until the exact ID is absent; Postgres deletion does not accept `--force`. Remove private credentials and certificates when no longer needed. Retain source and sanitized evidence. + +## References + +- [Panache queries, locks and transactions](https://quarkus.io/guides/hibernate-orm-panache/) +- [Quarkus security customization](https://quarkus.io/guides/security-customization/) +- [Quarkus Flyway](https://quarkus.io/guides/flyway/) +- [pgJDBC TLS verification](https://jdbc.postgresql.org/documentation/ssl/) +- [ClickHouse Managed Postgres overview](https://clickhouse.com/docs/products/managed-postgres/overview) diff --git a/applications/document-revisions/checks/cloud-controls.py b/applications/document-revisions/checks/cloud-controls.py new file mode 100755 index 00000000..00af1cb2 --- /dev/null +++ b/applications/document-revisions/checks/cloud-controls.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Real HTTP/Postgres checks. Requires runtime variables plus TEST_MIGRATOR_PASSWORD.""" +import concurrent.futures, json, os, subprocess, urllib.error, urllib.request +from pathlib import Path +from datetime import datetime, timezone + +BASE = "http://127.0.0.1:" + os.environ.get("QUARKUS_HTTP_PORT", "8080") +TOKEN = os.environ["ACCOUNT_001_TOKEN"] +OTHER = os.environ["ACCOUNT_002_TOKEN"] + +def request(method, path, body=None, token=TOKEN, raw=None): + payload = raw.encode() if raw is not None else (json.dumps(body).encode() if body is not None else None) + headers = {"Content-Type": "application/json"} + if token: headers["Authorization"] = "Bearer " + token + req = urllib.request.Request(BASE + path, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as r: + text = r.read().decode(); return r.status, json.loads(text) if text.startswith(("{", "[")) else text + except urllib.error.HTTPError as r: + text = r.read().decode(); return r.code, json.loads(text) if text.startswith(("{", "[")) else text + +def sql(statement, owner=False, expect_error=None): + env = dict(os.environ) + env["PGUSER"] = "revision_migrator" if owner else "revision_app" + env["PGPASSWORD"] = os.environ["TEST_MIGRATOR_PASSWORD"] if owner else os.environ["PGPASSWORD"] + prefix = "SET ROLE revision_owner; " if owner else "" + run = subprocess.run(["psql", "-XAt", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=verbose", "-c", prefix + statement], + env=env, text=True, capture_output=True, timeout=20) + if expect_error: + assert run.returncode and expect_error in run.stderr, (run.returncode, run.stderr) + print("SQL control:", expect_error) + else: + assert run.returncode == 0, run.stderr + return run.stdout.strip().removeprefix("SET\n") + +def expect(status, result): + assert result[0] == status, result + return result[1] + +def create(title="Initial", body="First content"): + return expect(201, request("POST", "/documents", {"title": title, "body": body})) + +def edit(doc, expected, title, body="New content"): + return request("POST", f"/documents/{doc}/revisions", {"expectedDraftRevision": expected, "title": title, "body": body}) + +def publish(doc, expected, number): + return request("POST", f"/documents/{doc}/publication", {"expectedPublicationVersion": expected, "revision": number}) + +def race(functions): + import threading + barrier = threading.Barrier(len(functions)) + def run(f): barrier.wait(); return f() + with concurrent.futures.ThreadPoolExecutor(max_workers=len(functions)) as pool: + return list(pool.map(run, functions)) + +expect(401, request("GET", "/documents", token=None)) +expect(401, request("GET", "/documents", token="f" * 64)) +for raw in ['{"title":"x","body":"x","accountId":"forged"}', '{"title":"x","body":"x"}{}', + '{"title":9,"body":"x"}', '{"title":"x","title":"y","body":"x"}', + '{"title":"x","body":"\\u0000"}', '{"title":"x","body":"\\ud800"}', 'null']: + expect(400, request("POST", "/documents", raw=raw)) +d = create(); doc = d["id"] +created = sql(f"SELECT (extract(epoch FROM created_at)*1000000)::bigint FROM revision_api.documents WHERE id='{doc}'") +instant = datetime.fromisoformat(d["createdAt"]) +delta = instant - datetime(1970,1,1,tzinfo=timezone.utc) +assert (delta.days*86400 + delta.seconds)*1000000 + delta.microseconds == int(created) +for field in (None, "1", 1.5): + expect(400, edit(doc, field, "Invalid")) +expect(404, request("GET", f"/documents/{doc}/draft", token=OTHER)) +expect(404, request("POST", f"/documents/{doc}/revisions", {"expectedDraftRevision":1,"title":"Foreign","body":"No"}, token=OTHER)) +expect(404, request("POST", f"/documents/{doc}/publication", {"expectedPublicationVersion":0,"revision":1}, token=OTHER)) +expect(404, request("GET", f"/documents/{doc}/published")) +expect(200, publish(doc, 0, 1)) +expect(200, edit(doc, 1, "Draft two", "Unpublished")) +assert expect(200, request("GET", f"/documents/{doc}/draft"))["body"] == "Unpublished" +assert expect(200, request("GET", f"/documents/{doc}/published"))["body"] == "First content" +expect(409, edit(doc, 1, "Stale")) +replay = expect(200, publish(doc, 0, 1)); assert replay["publicationVersion"] == 1 +expect(404, publish(doc, 1, 999)) +print("Authentication, strict parser/null/text controls, timestamp precision, ownership, draft/published separation and replay: passed") + +results = race([lambda: edit(doc, 2, "Editor one"), lambda: edit(doc, 2, "Editor two")]) +assert sorted(r[0] for r in results) == [200,409], results +assert sql(f"SELECT count(*) FROM revision_api.revisions WHERE document_id='{doc}'") == "3" +print("Concurrent HTTP editors:", sorted(r[0] for r in results), "three immutable revisions") +results = race([lambda: publish(doc, 1, 2), lambda: publish(doc, 1, 3)]) +assert sorted(r[0] for r in results) == [200,409], results +selected = next(r[1] for r in results if r[0] == 200) +assert selected["publicationVersion"] == 2 +again = expect(200, publish(doc, 1, selected["publishedRevision"])); assert again["publicationVersion"] == 2 +expect(409, publish(doc, 0, selected["publishedRevision"])) +print("Concurrent different-target publications:", sorted(r[0] for r in results), "identical retry preserved version 2") + +# Another document reaches revision four; the target document has only three. +b = create("Other document")["id"] +for number in (1,2,3): expect(200, edit(b, number, "Other revision")) +sql(f"BEGIN; UPDATE revision_api.documents SET published_revision=4, publication_version=3 WHERE id='{doc}'; COMMIT;", expect_error="23503") +assert sql(f"SELECT publication_version FROM revision_api.documents WHERE id='{doc}'") == "2" +sql(f"UPDATE revision_api.revisions SET body='tamper' WHERE document_id='{doc}';", expect_error="42501") +sql(f"DELETE FROM revision_api.revisions WHERE document_id='{doc}';", expect_error="42501") +sql("CREATE TABLE revision_api.forbidden(id integer);", expect_error="42501") +print("Cross-document deferred FK and immutable runtime permissions: passed") + +# Force failure AFTER the revision INSERT is flushed, on the subsequent document UPDATE. +sql("""CREATE FUNCTION revision_api.reject_pointer() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN IF NEW.title='rollback-fail' THEN RAISE EXCEPTION 'synthetic pointer failure'; END IF; RETURN NEW; END $$; +CREATE TRIGGER reject_pointer BEFORE UPDATE ON revision_api.documents FOR EACH ROW EXECUTE FUNCTION revision_api.reject_pointer();""", owner=True) +try: + expect(503, edit(doc, 3, "rollback-fail")) + assert sql(f"SELECT draft_revision FROM revision_api.documents WHERE id='{doc}'") == "3" + assert sql(f"SELECT count(*) FROM revision_api.revisions WHERE document_id='{doc}'") == "3" +finally: + sql("DROP TRIGGER reject_pointer ON revision_api.documents; DROP FUNCTION revision_api.reject_pointer();", owner=True) +print("Failure after flushed revision INSERT rolled back content and draft pointer: passed") + +# Method flushes both rows successfully; only deferred FK checking at COMMIT fails. +sql("""CREATE FUNCTION revision_api.break_commit() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN IF NEW.title='deferred-fail' THEN NEW.number=999; END IF; RETURN NEW; END $$; +CREATE TRIGGER break_commit BEFORE INSERT ON revision_api.revisions FOR EACH ROW EXECUTE FUNCTION revision_api.break_commit();""", owner=True) +try: + expect(503, request("POST", "/documents", {"title":"deferred-fail", "body":"Commit must fail"})) + assert sql("SELECT count(*) FROM revision_api.documents WHERE title='deferred-fail'") == "0" + assert sql("SELECT count(*) FROM revision_api.revisions WHERE title='deferred-fail'") == "0" +finally: + sql("DROP TRIGGER break_commit ON revision_api.revisions; DROP FUNCTION revision_api.break_commit();", owner=True) +print("Deferred FK COMMIT failure returned 503, never 201; both rows absent: passed") +expect(200, edit(doc, 3, "Recovered after rollback")) +assert len(expect(200, request("GET", f"/documents/{doc}/revisions"))) == 4 +assert expect(200, request("GET", f"/documents/{doc}/revisions/1"))["title"] == "Initial" +assert all(x["id"] != doc for x in expect(200, request("GET", "/documents", token=OTHER))) +Path(os.environ.get("EVIDENCE_DIR", "."), "restart-document.json").write_text(json.dumps({"id":doc,"draft":4,"published":selected["publishedRevision"]})) +print("Post-failure next transaction and bounded history/list scope: passed") +print("Live Cloud workflow controls passed") diff --git a/applications/document-revisions/checks/jdbc-url.py b/applications/document-revisions/checks/jdbc-url.py new file mode 100755 index 00000000..a9c4498e --- /dev/null +++ b/applications/document-revisions/checks/jdbc-url.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Print a JDBC endpoint without properties; passwords stay in separate variables.""" +import os, re +from urllib.parse import quote +host = os.environ["PGHOST"] +port = int(os.environ.get("PGPORT", "5432")) +database = os.environ.get("PGDATABASE", "postgres") +if not re.fullmatch(r"[A-Za-z0-9.-]+", host) or not 1 <= port <= 65535 or not database: + raise SystemExit("Invalid endpoint fields") +print(f"jdbc:postgresql://{host}:{port}/{quote(database, safe='')}") diff --git a/applications/document-revisions/checks/launch.py b/applications/document-revisions/checks/launch.py new file mode 100755 index 00000000..d0b75d2d --- /dev/null +++ b/applications/document-revisions/checks/launch.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Start the production jar with only runtime configuration and ordinary OS variables.""" +import os +from pathlib import Path +allowed = ("PATH", "HOME", "LANG", "JDBC_URL", "PGUSER", "PGPASSWORD", "PGSSLROOTCERT", + "ACCOUNT_001_TOKEN", "ACCOUNT_002_TOKEN", "QUARKUS_HTTP_PORT") +jar = Path(__file__).resolve().parents[1] / "target/quarkus-app/quarkus-run.jar" +os.execvpe("java", ["java", "-jar", str(jar)], {k: os.environ[k] for k in allowed if k in os.environ}) diff --git a/applications/document-revisions/checks/security-restart.py b/applications/document-revisions/checks/security-restart.py new file mode 100755 index 00000000..8b00baf9 --- /dev/null +++ b/applications/document-revisions/checks/security-restart.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Negative TLS through the actual production application, then genuine JVM restart.""" +import argparse, json, os, signal, socket, subprocess, time, urllib.error, urllib.request +from pathlib import Path + +APP = Path(__file__).resolve().parents[1] +EVIDENCE = Path(os.environ["EVIDENCE_DIR"]) +RUNTIME = ("PATH", "HOME", "LANG", "JDBC_URL", "PGUSER", "PGPASSWORD", "PGSSLROOTCERT", "ACCOUNT_001_TOKEN", "ACCOUNT_002_TOKEN") +base_env = {k:os.environ[k] for k in RUNTIME if k in os.environ} + +def get(port, path, expected): + req = urllib.request.Request(f"http://127.0.0.1:{port}"+path, headers={"Authorization":"Bearer "+os.environ["ACCOUNT_001_TOKEN"]}) + try: + with urllib.request.urlopen(req,timeout=20) as r: + status, body = r.status, r.read().decode() + except urllib.error.HTTPError as r: + status, body = r.code, r.read().decode() + assert status == expected, (status,body) + return json.loads(body) if body.startswith(("{","[")) else body + +def ready(port, child=None): + for _ in range(60): + if child is not None and child.poll() is not None: return False + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/live", timeout=1) as r: + if r.status==200: return True + except Exception: time.sleep(.5) + return False + +def negative(name, overrides, markers, port): + log = EVIDENCE/(name+".log") + env=dict(base_env, QUARKUS_HTTP_PORT=str(port), **overrides) + with log.open("w") as stream: + child=subprocess.Popen(["python3",str(APP/"checks/launch.py")],env=env,stdout=stream,stderr=subprocess.STDOUT) + try: + if ready(port,child): get(port,"/documents",503) + else: + assert child.poll() is not None, "TLS control timed out without a conclusive startup failure" + finally: + if child.poll() is None: child.terminate() + child.wait(timeout=20) + text=log.read_text() + marker=next((m for m in markers if m in text),None) + assert marker is not None, "Expected specific TLS diagnostic absent; inspect private log" + print(name, "specific driver cause:",marker) + +parser=argparse.ArgumentParser() +parser.add_argument("--restart-only", action="store_true", help="Repeat only the process/persistence check") +args=parser.parse_args() +if not args.restart_only: + get(8080,"/documents",200) + negative("wrong-ca", {"PGSSLROOTCERT":"/etc/ssl/certs/ca-certificates.crt"}, + ["PKIX path building failed", "unable to find valid certification path to requested target"],8081) + ip = next(row[4][0] for row in socket.getaddrinfo(os.environ["PGHOST"], int(os.environ["PGPORT"]),socket.AF_INET,socket.SOCK_STREAM)) + url = f"jdbc:postgresql://{ip}:{os.environ['PGPORT']}/{os.environ['PGDATABASE']}" + negative("wrong-hostname", {"JDBC_URL":url}, + ["could not be verified by hostnameverifier", "could not be verified by hostname verifier"],8082) + get(8080,"/documents",200) + print("Same official-CA DNS endpoint positive control remains healthy") + +saved=json.loads((EVIDENCE/"restart-document.json").read_text()) +before_draft=get(8080,f"/documents/{saved['id']}/draft",200) +before_published=get(8080,f"/documents/{saved['id']}/published",200) +old=int((EVIDENCE/"server.pid").read_text()) +os.kill(old,signal.SIGTERM) +for _ in range(100): + try: os.kill(old,0) + except ProcessLookupError: break + time.sleep(.1) +else: raise AssertionError("Original JVM still exists; do not start replacement") +with (EVIDENCE/"restart-server.log").open("w") as stream: + child=subprocess.Popen(["python3",str(APP/"checks/launch.py")],env=base_env,stdout=stream,stderr=subprocess.STDOUT,start_new_session=True) +(EVIDENCE/"server.pid").write_text(str(child.pid)) +assert ready(8080,child), "Replacement JVM did not become ready" +assert child.pid != old +draft=get(8080,f"/documents/{saved['id']}/draft",200) +published=get(8080,f"/documents/{saved['id']}/published",200) +assert draft["revision"]==saved["draft"] and published["revision"]==saved["published"] +assert draft==before_draft and published==before_published, "Full immutable content changed across restart" +print("Production JVM restart:",old,"→",child.pid,"original exited; persisted draft",draft["revision"],"published",published["revision"]) +print("Complete draft/published DTOs equal across restart (IDs, revision, title, body, timestamps)") +print("Genuine restart controls passed") diff --git a/applications/document-revisions/pom.xml b/applications/document-revisions/pom.xml new file mode 100644 index 00000000..60e8313e --- /dev/null +++ b/applications/document-revisions/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + com.clickhouse.examples + document-revisions + 1.0.0 + + 21 + UTF-8 + 3.40.1 + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-hibernate-validator + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-flyway + + + org.flywaydb + flyway-database-postgresql + + + org.junit.jupiter + junit-jupiter + test + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.16.0 + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.6.0 + + + org.jboss.logmanager.LogManager + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.5.0 + + + org.apache.maven.plugins + maven-dependency-plugin + 3.11.0 + + + + diff --git a/applications/document-revisions/sql/bootstrap.sql b/applications/document-revisions/sql/bootstrap.sql new file mode 100644 index 00000000..004a0609 --- /dev/null +++ b/applications/document-revisions/sql/bootstrap.sql @@ -0,0 +1,18 @@ +-- Run with the Cloud administrator and psql variables for the two passwords. +SELECT 'CREATE ROLE revision_owner NOLOGIN' +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'revision_owner') \gexec +SELECT 'CREATE ROLE revision_migrator LOGIN NOINHERIT' +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'revision_migrator') \gexec +SELECT 'CREATE ROLE revision_app LOGIN NOINHERIT' +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'revision_app') \gexec +ALTER ROLE revision_migrator PASSWORD :'migrator_password'; +ALTER ROLE revision_app PASSWORD :'app_password'; +GRANT revision_owner TO revision_migrator; +CREATE SCHEMA IF NOT EXISTS revision_api AUTHORIZATION revision_owner; +REVOKE ALL ON SCHEMA revision_api FROM PUBLIC; +GRANT USAGE ON SCHEMA revision_api TO revision_app; +ALTER ROLE revision_app SET search_path TO revision_api, pg_catalog; +ALTER ROLE revision_app SET statement_timeout TO '8s'; +ALTER ROLE revision_app SET lock_timeout TO '3s'; +ALTER ROLE revision_app SET idle_in_transaction_session_timeout TO '10s'; +ALTER ROLE revision_migrator SET statement_timeout TO '15s'; diff --git a/applications/document-revisions/sql/seed.sql b/applications/document-revisions/sql/seed.sql new file mode 100644 index 00000000..9c8f266b --- /dev/null +++ b/applications/document-revisions/sql/seed.sql @@ -0,0 +1,5 @@ +SET ROLE revision_owner; +INSERT INTO revision_api.accounts(id, name) VALUES + ('00000000-0000-0000-0000-000000000001', 'Editorial team'), + ('00000000-0000-0000-0000-000000000002', 'Support team') +ON CONFLICT (id) DO NOTHING; diff --git a/applications/document-revisions/src/main/java/example/BearerAuth.java b/applications/document-revisions/src/main/java/example/BearerAuth.java new file mode 100644 index 00000000..9c05eef6 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/BearerAuth.java @@ -0,0 +1,42 @@ +package example; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.*; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@ApplicationScoped +public class BearerAuth implements HttpAuthenticationMechanism { + @ConfigProperty(name = "accounts.first-token") String first; + @ConfigProperty(name = "accounts.second-token") String second; + @PostConstruct void validate() { + if (!first.matches("[0-9a-f]{64}") || !second.matches("[0-9a-f]{64}") || first.equals(second)) { + throw new IllegalStateException("Configure two distinct 256-bit hex account tokens"); + } + } + @Override public Uni authenticate(RoutingContext context, IdentityProviderManager ignored) { + String header = context.request().getHeader("Authorization"); + if (header == null) return Uni.createFrom().nullItem(); + if (!header.matches("Bearer [0-9a-f]{64}")) return Uni.createFrom().failure(new AuthenticationFailedException()); + byte[] supplied = header.substring(7).getBytes(StandardCharsets.US_ASCII); + boolean one = MessageDigest.isEqual(supplied, first.getBytes(StandardCharsets.US_ASCII)); + boolean two = MessageDigest.isEqual(supplied, second.getBytes(StandardCharsets.US_ASCII)); + if (!one && !two) return Uni.createFrom().failure(new AuthenticationFailedException()); + String account = one ? "00000000-0000-0000-0000-000000000001" : "00000000-0000-0000-0000-000000000002"; + return Uni.createFrom().item(QuarkusSecurityIdentity.builder().setPrincipal(() -> account).build()); + } + @Override public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(401, "WWW-Authenticate", "Bearer")); + } + @Override public Uni getCredentialTransport(RoutingContext context) { + return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.AUTHORIZATION, "Bearer")); + } +} diff --git a/applications/document-revisions/src/main/java/example/Document.java b/applications/document-revisions/src/main/java/example/Document.java new file mode 100644 index 00000000..47ee2ef5 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Document.java @@ -0,0 +1,18 @@ +package example; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "documents", schema = "revision_api") +public class Document extends PanacheEntityBase { + @Id public UUID id; + @Column(name = "account_id", nullable = false, updatable = false) public UUID accountId; + @Column(nullable = false) public String title; + @Column(name = "draft_revision", nullable = false) public long draftRevision; + @Column(name = "published_revision") public Long publishedRevision; + @Column(name = "publication_version", nullable = false) public long publicationVersion; + @Column(name = "created_at", nullable = false, updatable = false) public Instant createdAt; +} diff --git a/applications/document-revisions/src/main/java/example/DocumentsResource.java b/applications/document-revisions/src/main/java/example/DocumentsResource.java new file mode 100644 index 00000000..a4defa51 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/DocumentsResource.java @@ -0,0 +1,44 @@ +package example; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; +import java.util.List; +import java.util.UUID; + +@Path("/documents") +@Authenticated +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class DocumentsResource { + @Inject Workflow workflow; + @Inject SecurityIdentity identity; + private UUID account() { return UUID.fromString(identity.getPrincipal().getName()); } + + @POST public Response create(Dtos.Create input) { + var saved = workflow.create(account(), input); // CDI proxy commits before this returns. + return Response.status(201).entity(saved).build(); + } + @GET public List list() { return workflow.list(account()); } + @POST @Path("/{id}/revisions") public Dtos.Summary edit(@PathParam("id") String id, Dtos.Edit input) { + return workflow.edit(account(), Inputs.uuid(id), input); + } + @POST @Path("/{id}/publication") public Dtos.Summary publish(@PathParam("id") String id, Dtos.Publish input) { + return workflow.publish(account(), Inputs.uuid(id), input); + } + @GET @Path("/{id}/draft") public Dtos.Content draft(@PathParam("id") String id) { + return workflow.content(account(), Inputs.uuid(id), "draft", null); + } + @GET @Path("/{id}/published") public Dtos.Content published(@PathParam("id") String id) { + return workflow.content(account(), Inputs.uuid(id), "published", null); + } + @GET @Path("/{id}/revisions/{number}") public Dtos.Content historical( + @PathParam("id") String id, @PathParam("number") Long number) { + return workflow.content(account(), Inputs.uuid(id), "revision", number); + } + @GET @Path("/{id}/revisions") public List history(@PathParam("id") String id) { + return workflow.history(account(), Inputs.uuid(id)); + } +} diff --git a/applications/document-revisions/src/main/java/example/Dtos.java b/applications/document-revisions/src/main/java/example/Dtos.java new file mode 100644 index 00000000..28d90eb3 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Dtos.java @@ -0,0 +1,25 @@ +package example; + +import java.time.Instant; +import java.util.UUID; + +public final class Dtos { + private Dtos() {} + public record Create(String title, String body) {} + public record Edit(Long expectedDraftRevision, String title, String body) {} + public record Publish(Long expectedPublicationVersion, Long revision) {} + public record Summary(UUID id, String title, long draftRevision, Long publishedRevision, + long publicationVersion, Instant createdAt) { + public static Summary from(Document d) { + return new Summary(d.id, d.title, d.draftRevision, d.publishedRevision, + d.publicationVersion, d.createdAt); + } + } + public record Content(UUID documentId, long revision, String title, String body, Instant createdAt) { + public static Content from(Revision r) { + return new Content(r.documentId, r.number, r.title, r.body, r.createdAt); + } + } + public record RevisionSummary(long revision, String title, Instant createdAt) {} + public record Error(String message) {} +} diff --git a/applications/document-revisions/src/main/java/example/Inputs.java b/applications/document-revisions/src/main/java/example/Inputs.java new file mode 100644 index 00000000..6e3ee06e --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Inputs.java @@ -0,0 +1,41 @@ +package example; + +import jakarta.ws.rs.BadRequestException; +import java.util.UUID; + +public final class Inputs { + public static final long MAX_VERSION = 1_000_000_000L; + private Inputs() {} + + public static UUID uuid(String value) { + if (value == null || !value.matches("(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { + throw new BadRequestException("A canonical UUID is required"); + } + return UUID.fromString(value); + } + + public static long version(Long value, long minimum) { + if (value == null || value < minimum || value > MAX_VERSION) { + throw new BadRequestException("Version is outside the supported range"); + } + return value; + } + + public static String text(String value, int maximum, boolean multiline) { + if (value == null || value.isBlank() || value.length() > maximum) { + throw new BadRequestException("Text is missing or too long"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isHighSurrogate(c)) { + if (++i >= value.length() || !Character.isLowSurrogate(value.charAt(i))) { + throw new BadRequestException("Text contains invalid Unicode"); + } + } else if (Character.isLowSurrogate(c) + || (Character.isISOControl(c) && !(multiline && (c == '\n' || c == '\r' || c == '\t')))) { + throw new BadRequestException("Text contains unsupported characters"); + } + } + return value; + } +} diff --git a/applications/document-revisions/src/main/java/example/JdbcUrl.java b/applications/document-revisions/src/main/java/example/JdbcUrl.java new file mode 100644 index 00000000..6083933c --- /dev/null +++ b/applications/document-revisions/src/main/java/example/JdbcUrl.java @@ -0,0 +1,19 @@ +package example; + +import java.net.URI; + +/** Reject URL properties, which pgJDBC would prefer over separately configured TLS properties. */ +public final class JdbcUrl { + private JdbcUrl() {} + public static String validate(String value) { + if (value == null || !value.startsWith("jdbc:postgresql://")) invalid(); + URI uri; + try { uri = URI.create(value.substring(5)); } + catch (IllegalArgumentException error) { throw new IllegalArgumentException("Invalid JDBC endpoint"); } + if (uri.getHost() == null || uri.getPort() < 1 || uri.getPort() > 65535 + || uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null + || uri.getRawPath() == null || !uri.getRawPath().matches("/[A-Za-z0-9_%.-]+")) invalid(); + return value; + } + private static void invalid() { throw new IllegalArgumentException("Use a simple JDBC host:port/database URL without properties"); } +} diff --git a/applications/document-revisions/src/main/java/example/Launch.java b/applications/document-revisions/src/main/java/example/Launch.java new file mode 100644 index 00000000..e8db8a2e --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Launch.java @@ -0,0 +1,12 @@ +package example; + +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.annotations.QuarkusMain; + +@QuarkusMain +public class Launch { + public static void main(String[] args) { + JdbcUrl.validate(System.getenv("JDBC_URL")); // Before Quarkus creates any database pool. + Quarkus.run(args); + } +} diff --git a/applications/document-revisions/src/main/java/example/LiveResource.java b/applications/document-revisions/src/main/java/example/LiveResource.java new file mode 100644 index 00000000..b1bb9ee9 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/LiveResource.java @@ -0,0 +1,11 @@ +package example; + +import jakarta.annotation.security.PermitAll; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/live") +@PermitAll +public class LiveResource { + @GET public String live() { return "up"; } +} diff --git a/applications/document-revisions/src/main/java/example/Migrate.java b/applications/document-revisions/src/main/java/example/Migrate.java new file mode 100644 index 00000000..9a1209b6 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Migrate.java @@ -0,0 +1,28 @@ +package example; + +import org.flywaydb.core.Flyway; +import org.postgresql.ds.PGSimpleDataSource; + +/** Setup-only command: does not start Quarkus or the HTTP application. */ +public final class Migrate { + public static void main(String[] args) { + PGSimpleDataSource source = new PGSimpleDataSource(); + source.setURL(JdbcUrl.validate(required("JDBC_URL"))); + source.setUser("revision_migrator"); + source.setPassword(required("MIGRATOR_PASSWORD")); + source.setSslMode("verify-full"); + source.setSslRootCert(required("PGSSLROOTCERT")); + source.setConnectTimeout(5); + source.setSocketTimeout(15); + var result = Flyway.configure().dataSource(source) + .schemas("revision_api").defaultSchema("revision_api") + .locations("classpath:db/migration").initSql("SET ROLE revision_owner") + .cleanDisabled(true).load().migrate(); + System.out.println("Flyway migrations executed: " + result.migrationsExecuted); + } + private static String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + name); + return value; + } +} diff --git a/applications/document-revisions/src/main/java/example/PublicErrors.java b/applications/document-revisions/src/main/java/example/PublicErrors.java new file mode 100644 index 00000000..afea62a8 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/PublicErrors.java @@ -0,0 +1,28 @@ +package example; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.jboss.logging.Logger; + +@Provider +public class PublicErrors implements ExceptionMapper { + private static final Logger LOG = Logger.getLogger(PublicErrors.class); + @Override public Response toResponse(Exception error) { + if (error instanceof WebApplicationException web) { + int status = web.getResponse().getStatus(); + String message = switch (status) { + case 400 -> "Invalid request"; + case 401 -> "Authentication required"; + case 404 -> "Document or revision not found"; + case 409 -> "Version changed; refetch before retrying"; + default -> "Request rejected"; + }; + return Response.status(status).entity(new Dtos.Error(message)).build(); + } + // Infrastructure detail stays in operator logs, never in response DTOs. + LOG.error("Document transaction failed", error); + return Response.status(503).entity(new Dtos.Error("Database temporarily unavailable")).build(); + } +} diff --git a/applications/document-revisions/src/main/java/example/Revision.java b/applications/document-revisions/src/main/java/example/Revision.java new file mode 100644 index 00000000..49523fe0 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Revision.java @@ -0,0 +1,19 @@ +package example; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.*; +import org.hibernate.annotations.Immutable; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Immutable +@IdClass(RevisionKey.class) +@Table(name = "revisions", schema = "revision_api") +public class Revision extends PanacheEntityBase { + @Id @Column(name = "document_id") public UUID documentId; + @Id public long number; + @Column(nullable = false) public String title; + @Column(nullable = false, columnDefinition = "text") public String body; + @Column(name = "created_at", nullable = false) public Instant createdAt; +} diff --git a/applications/document-revisions/src/main/java/example/RevisionKey.java b/applications/document-revisions/src/main/java/example/RevisionKey.java new file mode 100644 index 00000000..0a63a835 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/RevisionKey.java @@ -0,0 +1,20 @@ +package example; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +public class RevisionKey implements Serializable { + public UUID documentId; + public long number; + public RevisionKey() {} + public RevisionKey(UUID documentId, long number) { + this.documentId = documentId; + this.number = number; + } + @Override public boolean equals(Object other) { + return other instanceof RevisionKey key && number == key.number + && Objects.equals(documentId, key.documentId); + } + @Override public int hashCode() { return Objects.hash(documentId, number); } +} diff --git a/applications/document-revisions/src/main/java/example/StrictJson.java b/applications/document-revisions/src/main/java/example/StrictJson.java new file mode 100644 index 00000000..a95ade66 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/StrictJson.java @@ -0,0 +1,25 @@ +package example; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; +import com.fasterxml.jackson.databind.type.LogicalType; +import io.quarkus.jackson.ObjectMapperCustomizer; +import jakarta.inject.Singleton; + +@Singleton +public class StrictJson implements ObjectMapperCustomizer { + @Override public void customize(ObjectMapper mapper) { + mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + mapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + mapper.coercionConfigFor(LogicalType.Integer) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.String, CoercionAction.Fail); + mapper.coercionConfigFor(LogicalType.Textual) + .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail); + } +} diff --git a/applications/document-revisions/src/main/java/example/Workflow.java b/applications/document-revisions/src/main/java/example/Workflow.java new file mode 100644 index 00000000..37ff5761 --- /dev/null +++ b/applications/document-revisions/src/main/java/example/Workflow.java @@ -0,0 +1,120 @@ +package example; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.LockModeType; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@ApplicationScoped +public class Workflow { + private Document document(UUID account, UUID id, boolean lock) { + var query = Document.find("accountId = ?1 and id = ?2", account, id); + if (lock) query.withLock(LockModeType.PESSIMISTIC_WRITE); + return query.firstResultOptional().orElseThrow(NotFoundException::new); + } + + private Revision revision(UUID id, long number) { + return Revision.find("documentId = ?1 and number = ?2", id, number) + .firstResultOptional().orElseThrow(NotFoundException::new); + } + + @Transactional + public Dtos.Summary create(UUID account, Dtos.Create input) { + if (input == null) throw new BadRequestException("A JSON object is required"); + String title = Inputs.text(input.title(), 120, false); + String body = Inputs.text(input.body(), 16_000, true); + Document d = new Document(); + d.id = UUID.randomUUID(); + d.accountId = account; + d.title = title; + d.draftRevision = 1; + d.createdAt = Instant.now().truncatedTo(ChronoUnit.MICROS); + d.persistAndFlush(); // Deferred draft FK is checked at commit, after the revision exists. + append(d, 1, title, body); + return Dtos.Summary.from(d); + } + + @Transactional + public Dtos.Summary edit(UUID account, UUID id, Dtos.Edit input) { + if (input == null) throw new BadRequestException("A JSON object is required"); + long expected = Inputs.version(input.expectedDraftRevision(), 1); + String title = Inputs.text(input.title(), 120, false); + String body = Inputs.text(input.body(), 16_000, true); + Document d = document(account, id, true); + if (d.draftRevision != expected) conflict("Draft changed; refetch before editing"); + if (d.draftRevision == Inputs.MAX_VERSION) conflict("Revision limit reached"); + long next = d.draftRevision + 1; + append(d, next, title, body); // Flush this INSERT before moving the pointer. + d.title = title; + d.draftRevision = next; + d.flush(); + return Dtos.Summary.from(d); + } + + private void append(Document d, long number, String title, String body) { + Revision r = new Revision(); + r.documentId = d.id; + r.number = number; + r.title = title; + r.body = body; + r.createdAt = Instant.now().truncatedTo(ChronoUnit.MICROS); + r.persistAndFlush(); + } + + @Transactional + public Dtos.Summary publish(UUID account, UUID id, Dtos.Publish input) { + if (input == null) throw new BadRequestException("A JSON object is required"); + long expected = Inputs.version(input.expectedPublicationVersion(), 0); + long selected = Inputs.version(input.revision(), 1); + Document d = document(account, id, true); + revision(d.id, selected); // Always scoped to this document, including replays. + boolean same = d.publishedRevision != null && d.publishedRevision == selected; + if (same && (expected == d.publicationVersion + || (d.publicationVersion > 0 && expected == d.publicationVersion - 1))) { + return Dtos.Summary.from(d); + } + if (expected != d.publicationVersion) conflict("Publication changed; refetch before publishing"); + if (d.publicationVersion == Inputs.MAX_VERSION) conflict("Publication limit reached"); + d.publishedRevision = selected; + d.publicationVersion++; + d.flush(); + return Dtos.Summary.from(d); + } + + @Transactional + public Dtos.Content content(UUID account, UUID id, String selection, Long number) { + Document d = document(account, id, false); + long chosen = switch (selection) { + case "draft" -> d.draftRevision; + case "published" -> { + if (d.publishedRevision == null) throw new NotFoundException(); + yield d.publishedRevision; + } + case "revision" -> Inputs.version(number, 1); + default -> throw new BadRequestException("Unknown content selection"); + }; + return Dtos.Content.from(revision(d.id, chosen)); + } + + @Transactional + public List list(UUID account) { + return Document.find("accountId = ?1 order by createdAt desc, id desc", account) + .range(0, 19).list().stream().map(Dtos.Summary::from).toList(); + } + + @Transactional + public List history(UUID account, UUID id) { + document(account, id, false); + return Revision.find("documentId = ?1 order by number desc", id) + .range(0, 99).list().stream() + .map(r -> new Dtos.RevisionSummary(r.number, r.title, r.createdAt)).toList(); + } + + private static void conflict(String message) { throw new WebApplicationException(message, 409); } +} diff --git a/applications/document-revisions/src/main/resources/application.properties b/applications/document-revisions/src/main/resources/application.properties new file mode 100644 index 00000000..5b86dfba --- /dev/null +++ b/applications/document-revisions/src/main/resources/application.properties @@ -0,0 +1,26 @@ +quarkus.http.host=127.0.0.1 +quarkus.http.port=8080 +quarkus.http.limits.max-body-size=64K +quarkus.datasource.db-kind=postgresql +quarkus.datasource.db-version=18.0 +quarkus.datasource.username=${PGUSER:revision_app} +quarkus.datasource.password=${PGPASSWORD:preflight-placeholder} +quarkus.datasource.jdbc.url=${JDBC_URL:jdbc:postgresql://unallocated.invalid:5432/postgres} +quarkus.datasource.jdbc.additional-jdbc-properties.sslmode=verify-full +quarkus.datasource.jdbc.additional-jdbc-properties.sslrootcert=${PGSSLROOTCERT:/nonexistent-preflight.pem} +quarkus.datasource.jdbc.additional-jdbc-properties.connectTimeout=5 +quarkus.datasource.jdbc.additional-jdbc-properties.socketTimeout=15 +quarkus.datasource.jdbc.min-size=0 +quarkus.datasource.jdbc.max-size=4 +quarkus.datasource.jdbc.acquisition-timeout=3S +quarkus.datasource.devservices.enabled=false +quarkus.devservices.enabled=false +quarkus.hibernate-orm.schema-management.strategy=none +quarkus.hibernate-orm.sql-load-script=no-file +quarkus.hibernate-orm.log.sql=false +quarkus.flyway.migrate-at-start=false +quarkus.flyway.validate-at-start=false +quarkus.flyway.clean-disabled=true +quarkus.flyway.schemas=revision_api +accounts.first-token=${ACCOUNT_001_TOKEN} +accounts.second-token=${ACCOUNT_002_TOKEN} diff --git a/applications/document-revisions/src/main/resources/db/migration/V1__documents.sql b/applications/document-revisions/src/main/resources/db/migration/V1__documents.sql new file mode 100644 index 00000000..b5d496cf --- /dev/null +++ b/applications/document-revisions/src/main/resources/db/migration/V1__documents.sql @@ -0,0 +1,38 @@ +CREATE TABLE revision_api.accounts ( + id uuid PRIMARY KEY, + name text NOT NULL CHECK (length(name) BETWEEN 1 AND 120) +); + +CREATE TABLE revision_api.documents ( + id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES revision_api.accounts(id), + title text NOT NULL CHECK (length(title) BETWEEN 1 AND 120), + draft_revision bigint NOT NULL CHECK (draft_revision BETWEEN 1 AND 1000000000), + published_revision bigint CHECK (published_revision BETWEEN 1 AND 1000000000), + publication_version bigint NOT NULL DEFAULT 0 CHECK (publication_version BETWEEN 0 AND 1000000000), + created_at timestamptz NOT NULL, + CHECK ((published_revision IS NULL) = (publication_version = 0)) +); + +CREATE TABLE revision_api.revisions ( + document_id uuid NOT NULL REFERENCES revision_api.documents(id), + number bigint NOT NULL CHECK (number BETWEEN 1 AND 1000000000), + title text NOT NULL CHECK (length(title) BETWEEN 1 AND 120), + body text NOT NULL CHECK (length(body) BETWEEN 1 AND 16000), + created_at timestamptz NOT NULL, + PRIMARY KEY (document_id, number) +); + +ALTER TABLE revision_api.documents + ADD CONSTRAINT draft_same_document FOREIGN KEY (id, draft_revision) + REFERENCES revision_api.revisions(document_id, number) DEFERRABLE INITIALLY DEFERRED, + ADD CONSTRAINT published_same_document FOREIGN KEY (id, published_revision) + REFERENCES revision_api.revisions(document_id, number) DEFERRABLE INITIALLY DEFERRED; + +CREATE INDEX documents_by_account ON revision_api.documents(account_id, created_at DESC, id DESC); + +GRANT SELECT ON revision_api.accounts TO revision_app; +GRANT SELECT, INSERT ON revision_api.documents TO revision_app; +GRANT UPDATE (title, draft_revision, published_revision, publication_version) + ON revision_api.documents TO revision_app; +GRANT SELECT, INSERT ON revision_api.revisions TO revision_app; diff --git a/applications/document-revisions/src/test/java/example/InputsTest.java b/applications/document-revisions/src/test/java/example/InputsTest.java new file mode 100644 index 00000000..c4712c10 --- /dev/null +++ b/applications/document-revisions/src/test/java/example/InputsTest.java @@ -0,0 +1,28 @@ +package example; + +import jakarta.ws.rs.BadRequestException; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class InputsTest { + @Test void nullAndCanonicalUuidBoundaries() { + assertThrows(BadRequestException.class, () -> Inputs.uuid(null)); + assertThrows(BadRequestException.class, () -> Inputs.uuid("1-1-1-1-1")); + assertEquals("abcdef00-0000-0000-0000-000000000001", + Inputs.uuid("ABCDEF00-0000-0000-0000-000000000001").toString()); + assertThrows(BadRequestException.class, () -> Inputs.version(null, 0)); + assertThrows(BadRequestException.class, () -> Inputs.version(-1L, 0)); + assertThrows(BadRequestException.class, () -> Inputs.version(1_000_000_001L, 0)); + assertEquals(0, Inputs.version(0L, 0)); + } + @Test void textRejectsDatabaseAndEncodingFailures() { + assertThrows(BadRequestException.class, () -> Inputs.text(null, 120, false)); + assertThrows(BadRequestException.class, () -> Inputs.text(" ", 120, false)); + assertThrows(BadRequestException.class, () -> Inputs.text("x".repeat(121), 120, false)); + assertThrows(BadRequestException.class, () -> Inputs.text("x\u0000", 120, true)); + assertThrows(BadRequestException.class, () -> Inputs.text("x\ud800", 120, true)); + assertThrows(BadRequestException.class, () -> Inputs.text("\udc00", 120, true)); + assertThrows(BadRequestException.class, () -> Inputs.text("title\n", 120, false)); + assertEquals("body\n\tšŸ˜€", Inputs.text("body\n\tšŸ˜€", 16_000, true)); + } +} diff --git a/applications/document-revisions/src/test/java/example/JdbcUrlTest.java b/applications/document-revisions/src/test/java/example/JdbcUrlTest.java new file mode 100644 index 00000000..6d520808 --- /dev/null +++ b/applications/document-revisions/src/test/java/example/JdbcUrlTest.java @@ -0,0 +1,15 @@ +package example; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class JdbcUrlTest { + @Test void urlCannotOverrideTlsProperties() { + assertEquals("jdbc:postgresql://db.example:5432/postgres", JdbcUrl.validate("jdbc:postgresql://db.example:5432/postgres")); + for (String url : new String[] {"jdbc:postgresql://db.example:5432/postgres?sslmode=disable", + "jdbc:postgresql://user@db.example:5432/postgres", "jdbc:postgresql://db.example:5432/postgres#x", + "jdbc:postgresql://db.example/postgres", "jdbc:postgresql://db.example:99999/postgres"}) { + assertThrows(IllegalArgumentException.class, () -> JdbcUrl.validate(url)); + } + } +} From 171be24f1b2bd97ae2f75bb4624656088f9354ae Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 20:09:45 +0100 Subject: [PATCH 2/3] Link verified clickhousectl setup instructions --- applications/document-revisions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/document-revisions/README.md b/applications/document-revisions/README.md index c546f38c..0408b7f3 100644 --- a/applications/document-revisions/README.md +++ b/applications/document-revisions/README.md @@ -31,7 +31,7 @@ This runs input and JDBC endpoint boundary tests and produces `target/quarkus-ap ## Create an isolated Cloud database -Install [clickhousectl](https://clickhouse.com/docs/cloud/manage/cli), authenticate with your own Cloud API key, and select your organization. These commands use clickhousectl 0.5.0. The example creates a billable AWS service without HA; choose a supported size and region for your organization. +Install [clickhousectl](https://clickhouse.com/blog/getting-started-clickhousectl), authenticate with your own Cloud API key, and select your organization. These commands use clickhousectl 0.5.0. The example creates a billable AWS service without HA; choose a supported size and region for your organization. ```bash umask 077 From ff214ac4d88e6c0e7ccecb2beeac3c390ca197d2 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 21:31:17 +0100 Subject: [PATCH 3/3] docs: remove public beta wording --- applications/document-revisions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/document-revisions/README.md b/applications/document-revisions/README.md index 0408b7f3..6c3a8a9a 100644 --- a/applications/document-revisions/README.md +++ b/applications/document-revisions/README.md @@ -1,6 +1,6 @@ # Document revisions with Quarkus and Panache -A JSON API for account-owned documents on ClickHouse Managed Postgres (public beta). Editing appends an immutable text snapshot and advances a draft pointer. Publishing independently selects an existing revision. New drafts leave published content unchanged. +A JSON API for account-owned documents on ClickHouse Managed Postgres. Editing appends an immutable text snapshot and advances a draft pointer. Publishing independently selects an existing revision. New drafts leave published content unchanged. Quarkus REST handles HTTP, native Quarkus security maps two seeded accounts to bearer tokens, Hibernate ORM Panache supplies scoped queries and row locks, and Flyway owns schema changes. The application runs on the JVM. It has no frontend, arbitrary HTML, external storage or real deployment integration.