feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
-233
View File
@@ -1,233 +0,0 @@
# ----------------------------------------------------------------------------
# 외부화 설정의 단일 출처. spring-dotenv 가 src/.env 에서 로드합니다
# (bootRun.workingDir = src/). 각 키의 허용값·결정 근거는 src/README.md 참조.
# ----------------------------------------------------------------------------
# ----- App identity -----
APP_NAME=ca-skeleton
SPRING_PROFILES_ACTIVE=local
# ----- Runtime safety (StartupSafetyValidator, D8) -----
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
APP_LOG_BODY_CAPTURE_ENABLED=false
APP_MULTI_INSTANCE_ENABLED=false
APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_PROVIDER=disabled
APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
# ----- Async executor -----
APP_ASYNC_EXECUTOR_CORE_SIZE=10
APP_ASYNC_EXECUTOR_MAX_SIZE=50
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
# The single global Redis switch. False means no Redis settings, secrets, client, threads or
# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose
# which capabilities compose once Redis is on; none of them turns Redis on.
APP_REDIS_ENABLED=false
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER=
# ----- Logging: root & app levels -----
APP_LOG_LEVEL_ROOT=INFO
APP_LOG_LEVEL_APP=DEBUG
# ----- Logging: per-package levels -----
APP_LOG_LEVEL_SPRING=INFO
APP_LOG_LEVEL_WEB=INFO
APP_LOG_LEVEL_SQL=WARN
# ----- Logging: file output + rolling -----
APP_LOG_FILE_ENABLED=false
APP_LOG_FILE_PATH=logs/ca-skeleton.json
APP_LOG_FILE_MAX_SIZE=100MB
APP_LOG_FILE_MAX_HISTORY=14
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
# ----- Logging: async appender -----
APP_LOG_ASYNC_ENABLED=true
APP_LOG_ASYNC_QUEUE_SIZE=512
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
# ----- Logging: JSON encoder -----
APP_LOG_JSON_TIMEZONE=UTC
APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX
APP_LOG_JSON_INCLUDE_CALLER_DATA=false
APP_LOG_JSON_LOGGER_NAME_LENGTH=0
# ----- Logging: sampling -----
APP_LOG_SAMPLING_RATE=1.0
# ----- Distributed tracing -----
OTEL_EXPORTER_OTLP_ENDPOINT=
APP_TRACING_ENABLED=true
APP_TRACING_SAMPLE_RATE=
# ----- Privacy: user_principal pseudonymization -----
APP_PRIVACY_PSEUDONYMIZATION_SALT=__LOCAL_DEV_pseudonymization_salt
# ----- Spring Boot bootstrap -----
SPRING_BANNER_MODE=console
SPRING_MAIN_LAZY_INITIALIZATION=false
SPRING_MAIN_LOG_STARTUP_INFO=true
SPRING_THREADS_VIRTUAL_ENABLED=true
# ----- Jackson: deserialization policy -----
SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES=true
SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES=true
SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES=true
SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL=false
# ----- Jackson: serialization policy -----
SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS=false
# ----- Server / Tomcat -----
APP_SERVER_PORT=8080
APP_SERVER_SHUTDOWN=graceful
APP_SERVER_SHUTDOWN_TIMEOUT=30s
APP_SERVER_TOMCAT_MAX_THREADS=200
APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10
APP_SERVER_TOMCAT_ACCEPT_COUNT=100
APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192
APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s
APP_SERVER_COMPRESSION_ENABLED=true
APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1024
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
# ----- Presentation -----
PRESENTATION_API_BASE_PATH=/api
# ----- Auth (OIDC resource server) -----
APP_SECURITY_AUTH_MODE=jwt
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck
APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true
APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
# ----- CORS -----
APP_SECURITY_CORS_ENABLED=true
APP_SECURITY_CORS_ORIGINS=http://localhost:3000
APP_SECURITY_CORS_ALLOWED_METHODS=
APP_SECURITY_CORS_ALLOWED_HEADERS=*
APP_SECURITY_CORS_ALLOW_CREDENTIALS=true
APP_SECURITY_CORS_MAX_AGE=3600
# ----- Database (Postgres) -----
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton
APP_DATASOURCE_USERNAME=ca_skeleton
APP_DATASOURCE_PASSWORD=ca_skeleton
APP_DATASOURCE_DRIVER=org.postgresql.Driver
APP_DATASOURCE_DDL_AUTO=update
APP_DATASOURCE_SHOW_SQL=false
APP_DATASOURCE_FORMAT_SQL=false
APP_DATASOURCE_OPEN_IN_VIEW=false
# ----- Database: HikariCP connection pool -----
APP_DATASOURCE_POOL_MAX_SIZE=10
APP_DATASOURCE_POOL_MIN_IDLE=2
APP_DATASOURCE_CONNECTION_TIMEOUT=30000
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
# ----- Management / Actuator -----
MANAGEMENT_SERVER_PORT=9001
# ----- Fileserver HTTP platform (app.fileserver-platform.*) -----
# Off by default. While false nothing below is bound: the platform auto-configuration binds this
# block itself and is not processed until the master switch is true.
APP_FILESERVER_PLATFORM_ENABLED=false
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
# Storage root must be an absolute path on its own volume, never under a web or config root.
APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files
APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred
APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB
APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html
# Shared with spring.servlet.multipart.* so the container and the policy cannot disagree.
APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB
APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16
APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h
APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h
APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s
APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false
APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store
APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB
APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8
APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32
APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64
APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300
# required | role-based | unenforced (unenforced is refused under a production profile).
APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required
APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ
APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE
APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN
APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s
APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false
APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false
APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16
APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4
APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64
APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70
APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85
APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false
APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h
APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false
APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s
APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100
APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB
APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m
APP_FILESERVER_PLATFORM_TUS_ENABLED=false
APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/
APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin
APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB
APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true
# Secret. Required while metrics are enabled; an unkeyed digest of an enumerable id is reversible.
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
# ----- HTTP Client platform (app.httpclient.*) -----
# The single switch for outbound HTTP. False means no HTTP client property is bound, and no
# transport provider, connection pool, TLS context, credential, thread or gateway is created.
# The per-client surface is indexed and per-deployment, so it is set directly in the environment
# rather than declared here; docs/httpclient/env-fields.yaml is its registry, and an
# APP_HTTPCLIENT_ variable that is not in that registry fails startup.
APP_HTTPCLIENT_ENABLED=false
+369
View File
@@ -0,0 +1,369 @@
# =============================================================================
# The public catalogue of every environment variable this application reads.
#
# Generated from docs/registries/env-keys.yaml, which is the SSOT. Copy this file to src/.env and
# fill in the values your deployment needs; src/.env is gitignored because it is operator input,
# not a build input.
#
# Secret-classified keys are left empty on purpose. An example that carries a working credential
# is a credential in the repository, and the fact that it is 'only an example' is not something a
# scanner, a fork, or a hurried operator can tell.
#
# A key left blank here uses the inline default in application.yml. Seven values have no default
# at all — the datasource URL, username and password, the application name, and the JWT issuer and
# audience — because a default for any of them is a deployment running against something nobody
# chose.
# =============================================================================
# ---- Configuration -----------------------------------------------------------
SPRING_PROFILES_ACTIVE=
APP_NAME=
APP_SERVER_PORT=8080
MANAGEMENT_SERVER_PORT=9001
APP_SERVER_SHUTDOWN=graceful
APP_SERVER_SHUTDOWN_TIMEOUT=30s
APP_SERVER_FORWARD_HEADERS_STRATEGY=framework
APP_SERVER_TOMCAT_MAX_THREADS=200
APP_SERVER_TOMCAT_MIN_SPARE_THREADS=10
APP_SERVER_TOMCAT_ACCEPT_COUNT=100
APP_SERVER_TOMCAT_MAX_CONNECTIONS=8192
APP_SERVER_TOMCAT_CONNECTION_TIMEOUT=20s
APP_SERVER_COMPRESSION_ENABLED=true
APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE=1KB
APP_SERVER_ERROR_INCLUDE_STACKTRACE=never
APP_SERVER_ERROR_INCLUDE_MESSAGE=never
APP_ERROR_DETAIL_EXPOSURE_ENABLED=false
APP_LOG_BODY_CAPTURE_ENABLED=false
APP_MULTI_INSTANCE_ENABLED=false
APP_MIGRATION_ON_STARTUP=true
APP_DATASOURCE_URL=
APP_DATASOURCE_USERNAME=
APP_DATASOURCE_POOL_MAX_SIZE=10
APP_DATASOURCE_POOL_MIN_IDLE=2
APP_DATASOURCE_CONNECTION_TIMEOUT=5s
APP_DATASOURCE_DRIVER=org.postgresql.Driver
APP_DATASOURCE_DDL_AUTO=validate
APP_DATASOURCE_SHOW_SQL=false
APP_DATASOURCE_FORMAT_SQL=false
APP_DATASOURCE_OPEN_IN_VIEW=false
APP_DATASOURCE_POOL_IDLE_TIMEOUT=600000
APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
OTEL_EXPORTER_OTLP_ENDPOINT=
APP_TRACING_ENABLED=true
APP_TRACING_SAMPLE_RATE=
APP_LOG_LEVEL_ROOT=INFO
APP_LOG_LEVEL_APP=INFO
APP_LOG_LEVEL_SPRING=INFO
APP_LOG_LEVEL_WEB=INFO
APP_LOG_LEVEL_SQL=WARN
APP_LOG_FILE_ENABLED=false
APP_LOG_FILE_PATH=logs/ca-skeleton.json
APP_LOG_FILE_MAX_SIZE=100MB
APP_LOG_FILE_MAX_HISTORY=14
APP_LOG_FILE_TOTAL_SIZE_CAP=3GB
APP_LOG_ASYNC_ENABLED=true
APP_LOG_ASYNC_QUEUE_SIZE=512
APP_LOG_ASYNC_DISCARDING_THRESHOLD=20
APP_LOG_JSON_TIMEZONE=UTC
APP_LOG_JSON_TIMESTAMP_PATTERN=yyyy-MM-dd'T'HH:mm:ss.SSSXXX
APP_LOG_JSON_INCLUDE_CALLER_DATA=false
APP_LOG_JSON_LOGGER_NAME_LENGTH=0
APP_LOG_SAMPLING_RATE=1.0
APP_SECURITY_CORS_ORIGINS=
APP_SECURITY_CORS_ENABLED=false
APP_SECURITY_CORS_ALLOWED_METHODS=
APP_SECURITY_CORS_ALLOWED_HEADERS=*
APP_SECURITY_CORS_ALLOW_CREDENTIALS=false
APP_SECURITY_CORS_MAX_AGE=600s
APP_SECURITY_AUTH_MODE=jwt
APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true
APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local
APP_SESSION_IDLE_TIMEOUT=30m
APP_SESSION_ABSOLUTE_LIFETIME=8h
APP_SESSION_TOUCH_INTERVAL=1m
APP_SESSION_TOMBSTONE_TTL=5m
APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768
APP_SESSION_MAXIMUM_ATTRIBUTES=64
APP_SESSION_MAXIMUM_SCALAR_BYTES=8192
APP_SECURITY_JWT_ISSUER=
APP_SECURITY_JWT_AUDIENCE=
APP_SECURITY_JWT_JWKS_URI=
APP_SECURITY_JWT_CLOCK_SKEW=60s
APP_TENANT_ENABLED=false
APP_RATE_LIMIT_ENABLED=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_REDIS_ENABLED=false
APP_RATE_LIMIT_PROVIDER=disabled
APP_RATE_LIMIT_ROLE=coordination
APP_RATE_LIMIT_FAILURE_POLICY=fail-closed
APP_RATE_LIMIT_DEFAULT_POLICY_ID=api-default
APP_RATE_LIMIT_FAILURE_RETRY_AFTER=100ms
APP_RATE_LIMIT_HASH_KEY_VERSION=1
APP_RATE_LIMIT_KEY_VERSION=1
APP_RATE_LIMIT_REDIS_HOST=
APP_RATE_LIMIT_REDIS_PORT=6379
APP_RATE_LIMIT_REDIS_TRUST_PEM=
APP_SESSION_REDIS_TRUST_PEM=
APP_RATE_LIMIT_REDIS_COMMAND_TIMEOUT=1s
APP_RATE_LIMIT_REDIS_MAXIMUM_COMMAND_BYTES=16384
APP_RATE_LIMIT_REDIS_MAXIMUM_QUEUED_COMMANDS=32
APP_RATE_LIMIT_REDIS_MAXIMUM_IN_FLIGHT_BYTES=1048576
APP_RATE_LIMIT_REDIS_NAMESPACE_ENVIRONMENT=local
APP_RATE_LIMIT_POLICY_REVISION=v1
APP_RATE_LIMIT_ALGORITHM=sliding-counter
APP_RATE_LIMIT_LIMIT=100
APP_RATE_LIMIT_WINDOW=1s
APP_RATE_LIMIT_CAPACITY=100
APP_RATE_LIMIT_REFILL_TOKENS=100
APP_RATE_LIMIT_REFILL_PERIOD=1s
APP_RATE_LIMIT_MAXIMUM_COST=10
APP_RATE_LIMIT_CLEANUP_GRACE=5s
APP_RATE_LIMIT_MAXIMUM_CLOCK_REGRESSION=250ms
APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
APP_REDIS_ENABLED=false
APP_CACHE_REDIS_POSITIVE_HARD_TTL=5m
APP_CACHE_REDIS_NEGATIVE_TTL=10s
APP_IDEMPOTENCY_REDIS_COMMAND_TIMEOUT=200ms
APP_LEASE_REDIS_COMMAND_TIMEOUT=200ms
APP_LEASE_REDIS_CONTENTION_RETRY_AFTER=50ms
APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED=false
APP_REDIS_ADMIN_CREDENTIAL_REFERENCE=
APP_REDIS_ADMIN_ENABLED=false
APP_REDIS_ADVANCED_ENABLED=false
APP_REDIS_ADVANCED_POLICIES=
APP_REDIS_BLOCKING_MAX_BLOCK=30s
APP_REDIS_BLOCKING_MAX_CONNECTIONS=32
APP_REDIS_DATABASE=0
APP_REDIS_LIMITS_MAX_BATCH_COMMANDS=500
APP_REDIS_LIMITS_MAX_BATCH_REPLY_BYTES=16777216
APP_REDIS_LIMITS_MAX_BATCH_REQUEST_BYTES=4194304
APP_REDIS_LIMITS_MAX_BITMAP_OFFSET=10000000
APP_REDIS_LIMITS_MAX_COLLECTION_ELEMENTS=1000
APP_REDIS_LIMITS_MAX_HASH_FIELD_VALUE_BYTES=524288
APP_REDIS_LIMITS_MAX_KEY_BYTES=512
APP_REDIS_LIMITS_MAX_SCAN_COUNT=500
APP_REDIS_LIMITS_MAX_STREAM_PAYLOAD_BYTES=262144
APP_REDIS_LIMITS_MAX_VALUE_BYTES=1048576
APP_REDIS_LIMITS_OFFLINE_QUEUE_COMMANDS=1000
APP_REDIS_MODE=standalone
APP_REDIS_NAMESPACE_DOMAIN=shared
APP_REDIS_NAMESPACE_ENVIRONMENT=local
APP_REDIS_NAMESPACE_SERVICE=sample-service
APP_REDIS_NODES=localhost:6379
APP_REDIS_RAW_CREDENTIAL_REFERENCE=
APP_REDIS_RAW_ENABLED=false
APP_REDIS_RAW_POLICY_RESOURCE=classpath:redis-sdk/raw-command-allowlist.yml
APP_REDIS_TIMEOUT_ADMIN=3s
APP_REDIS_TIMEOUT_BATCH=2s
APP_REDIS_TIMEOUT_COLLECTION=2s
APP_REDIS_TIMEOUT_FAST=500ms
APP_REDIS_TIMEOUT_SCRIPT=1s
APP_REDIS_TRANSACTION_MAX_CONNECTIONS=16
APP_REDIS_AUTHENTICATION_ADVANCED_CREDENTIAL_REFERENCE=
APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED=false
APP_REDIS_AUTHENTICATION_CREDENTIAL_REFERENCE=
APP_REDIS_AUTHENTICATION_PUBSUB_CREDENTIAL_REFERENCE=
APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_BYTES=
APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_COMMANDS=64
APP_REDIS_CAPACITY_MAXIMUM_REPLY_BYTES=
APP_REDIS_CAPACITY_REJECT_WHEN_DISCONNECTED=true
APP_REDIS_CLUSTER_MAXIMUM_REDIRECTS=5
APP_REDIS_CLUSTER_TOPOLOGY_REFRESH_PERIOD=30s
APP_REDIS_LIFECYCLE_ACQUIRE_TIMEOUT=2s
APP_REDIS_LIFECYCLE_CLIENT_NAME=ca-skeleton
APP_REDIS_LIFECYCLE_CONNECT_TIMEOUT=2s
APP_REDIS_LIFECYCLE_DRAIN_TIMEOUT=6s
APP_REDIS_LIFECYCLE_SHUTDOWN_QUIET_PERIOD=100ms
APP_REDIS_LIFECYCLE_SHUTDOWN_TIMEOUT=3s
APP_REDIS_LIFECYCLE_TLS_HANDSHAKE_TIMEOUT=3s
APP_REDIS_PUBSUB_BUFFER_CAPACITY=1024
APP_REDIS_PUBSUB_OVERFLOW_POLICY=error
APP_REDIS_SENTINEL_CREDENTIAL_REFERENCE=
APP_REDIS_SENTINEL_MASTER_NAME=
APP_REDIS_SENTINEL_NODES=app.redis.nodes
APP_REDIS_TLS_CLIENT_CERTIFICATE_RESOURCE=
APP_REDIS_TLS_CLIENT_KEY_REFERENCE=
APP_REDIS_TLS_ENABLED=false
APP_REDIS_TLS_HOSTNAME_VERIFICATION=true
APP_REDIS_TLS_TRUST_MATERIAL_RESOURCE=
APP_CACHE_REDIS_ENABLED=false
APP_CACHE_REDIS_CLIENT_MODE=managed
APP_CACHE_REDIS_HOST=
APP_CACHE_REDIS_PORT=6379
APP_CACHE_REDIS_TRUST_PEM=
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s
APP_CACHE_REDIS_COMMAND_TIMEOUT=2s
APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8
APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
APP_CACHE_REDIS_POSITIVE_SOFT_TTL=
APP_CACHE_REDIS_TTL_JITTER=0.10
APP_CACHE_REDIS_MINIMUM_HARD_TTL=1s
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_CACHE_REDIS_SEMANTIC_REGION=default
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
APP_CACHE_REDIS_L1_ENABLED=false
APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000
APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864
APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576
APP_CACHE_REDIS_L1_TTL=30s
APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s
APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024
APP_CACHE_DEFAULT_TTL=300s
APP_CACHE_NEGATIVE_TTL=60s
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
APP_NOTIFICATION_EMAIL_PROVIDER=
APP_FILESERVER_ENABLED=false
APP_FILESERVER_LOCAL_ROOT=
APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_NAME=
APP_FILESERVER_LOCAL_EXPECTED_FILE_STORE_TYPE=
APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256=
APP_FILESERVER_LOCAL_EXPECTED_OWNER=
APP_HTTPCLIENT_ENABLED=false
APP_FILESERVER_PLATFORM_ENABLED=false
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files
APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred
APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB
APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html
APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB
APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16
APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h
APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h
APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s
APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false
APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store
APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB
APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8
APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32
APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64
APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300
APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required
APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ
APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE
APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN
APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s
APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false
APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false
APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16
APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4
APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64
APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70
APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85
APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false
APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h
APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false
APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s
APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100
APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB
APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m
APP_FILESERVER_PLATFORM_TUS_ENABLED=false
APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/
APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin
APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB
APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true
APP_FILE_UPLOAD_MAX_SIZE=10MB
APP_FILE_UPLOAD_GLOBAL_REQUEST_MAX_SIZE=12MB
APP_ASYNC_EXECUTOR_CORE_SIZE=10
APP_ASYNC_EXECUTOR_MAX_SIZE=50
APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
APP_PERSISTENCE_JPA_ENABLED=false
APP_PERSISTENCE_MONGO_ENABLED=false
APP_PERSISTENCE_MONGO_ACTIVE_PROFILE=
APP_MESSAGING_ENABLED=false
APP_GRAPHQL_ENABLED=false
APP_GRAPHQL_DEPLOYMENT_MODE=
APP_OUTBOX_ENABLED=false
APP_OUTBOX_RELAY_ENABLED=false
APP_NOTIFICATION_PLATFORM_ENABLED=false
APP_NOTIFICATION_PLATFORM_MODE=SERVING
# OpenAPI exposure. application-prod.yml pins both false regardless of these.
APP_OPENAPI_DOCS_ENABLED=true
APP_OPENAPI_UI_ENABLED=true
# The shipped SMTP provider profile and the relay it uses. Off by default; the relay address
# itself is spring.mail.* (SPRING_MAIL_HOST / SPRING_MAIL_PORT), not repeated here.
APP_NOTIFICATION_PLATFORM_SMTP_ENABLED=false
APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY=true
APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT=local
APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE=default
APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT=10s
APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY=4
APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND=10
APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE=STARTTLS_REQUIRED
APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY=no-reply@example.invalid
APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT=5s
APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT=10s
APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT=10s
APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY=4
APP_NOTIFICATION_PLATFORM_CLAIM_BATCH_SIZE=50
APP_NOTIFICATION_PLATFORM_LEASE_DURATION=2m
APP_NOTIFICATION_PLATFORM_POLL_INTERVAL=1s
APP_NOTIFICATION_PLATFORM_MAX_CONCURRENCY=64
APP_NOTIFICATION_PLATFORM_MAX_ADDITIONAL_ATTEMPTS=4
APP_NOTIFICATION_PLATFORM_MAX_QUEUE_AGE=24h
APP_NOTIFICATION_PLATFORM_ALLOW_AMBIGUOUS_FALLBACK=false
APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED=false
APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES=65508
APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW=5m
# ---- Secrets — supply out of band; never commit a value here ------------------
APP_DATASOURCE_PASSWORD=
APP_PRIVACY_PSEUDONYMIZATION_SALT=
APP_SECURITY_JWT_SIGNING_KEY=
APP_RATE_LIMIT_REDIS_PASSWORD=
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
APP_SESSION_REDIS_PASSWORD=
APP_SESSION_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_CACHE_REDIS_PASSWORD=
APP_CACHE_REDIS_KEY_HMAC_SECRET=
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
# The notification platform's eight key purposes. Each is base64 of at least 32 bytes and must
# differ from the other seven; the platform decodes all eight at startup and refuses to boot if one
# is blank, short or shared. Only needed when APP_NOTIFICATION_PLATFORM_ENABLED=true.
APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY=
APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY=
APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY=
APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY=
APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY=
APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY=
APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY=
APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY=
# The id each of those keys is active under — an identifier, not secret material, and required:
# rotating the material without changing the id makes the new ciphertext indistinguishable from the
# old. Any stable string per purpose; change it whenever the matching key changes.
APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID=
APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID=
APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID=
APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID=
APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID=
APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID=
APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID=
APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID=
+36
View File
@@ -0,0 +1,36 @@
# =============================================================================
# A local opt-in example: the five adapters are off in the shipped defaults, and this shows what
# turning some of them on looks like. Copy to src/.env.local and edit.
#
# Each switch here needs the infrastructure its Compose profile provides. Turning one on without
# it is refused at startup by CapabilityDependencyValidator, which names the missing value.
# =============================================================================
SPRING_PROFILES_ACTIVE=local
# JPA: needs the PostgreSQL service. Flyway owns the schema from dev onward, and local uses the
# same vendor semantics so the two do not diverge.
APP_PERSISTENCE_JPA_ENABLED=true
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5432/ca_skeleton
APP_DATASOURCE_USERNAME=ca_skeleton
APP_DATASOURCE_PASSWORD=
APP_DATASOURCE_DDL_AUTO=validate
# Messaging: the broker id selects the transport; the switch decides whether there is one at all.
APP_MESSAGING_ENABLED=false
APP_MESSAGING_BROKER=
# Outbox: needs JPA and messaging together, and a broker. relay-enabled only starts the scheduler.
APP_OUTBOX_ENABLED=false
APP_OUTBOX_RELAY_ENABLED=false
# Mongo: active-profile is required when the switch is on, and selects exactly one client.
APP_PERSISTENCE_MONGO_ENABLED=false
APP_PERSISTENCE_MONGO_ACTIVE_PROFILE=
# GraphQL: the deployment mode must match the runtime environment (local -> LOCAL).
APP_GRAPHQL_ENABLED=false
APP_GRAPHQL_DEPLOYMENT_MODE=LOCAL
# Notification: stores requests relationally, so it needs JPA on.
APP_NOTIFICATION_PLATFORM_ENABLED=false
+13 -2
View File
@@ -21,8 +21,9 @@ leaf 안의 **bounded sub-package** 로 매핑한다(선례: httpclient leaf 가
SSOT(`src/config/architecture/modules.json`)까지 밀어올리지 않는다는 선택이다.
**이는 레지스트리가 닫혀 있어서가 아니다.** `modules.json` 은 확장 가능하며, 실제로 자매
플랫폼인 messaging 은 **정반대 선택**을 해서 24개 leaf 레지스트리에 등록했다(현재 총 43개
leaf). 즉 이 레포에는 두 패턴이 공존한다:
플랫폼인 messaging 은 **정반대 선택**을 해서 자기 leaf 들을 레지스트리에 개별 등록했다. 개수는
`modules.json` 이 소유하며 여기서 되풀이하지 않는다 — 산문에 적힌 숫자는 leaf 가 하나 추가되는 순간
낡는다. 즉 이 레포에는 두 패턴이 공존한다:
| | 방식 | 경계 강제 |
| --- | --- | --- |
@@ -140,6 +141,8 @@ health 스키마만 소유한다.
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` |
| cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다**`autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 |
| mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 |
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
| subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 `*Admission` 이다) |
| federation / incremental / codegen / compat | `modelled` | 단위 테스트만 |
@@ -157,6 +160,14 @@ health 스키마만 소유한다.
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다. 이 testkit 은
**production jar 에 없다**`src/testFixtures/java` 에 살고 `verifyGraphQlProductionJar`
그 사실을 jar 내용으로 확인한다.
- **cursor 서명이 요청 경로에 없다 (GQL-INT-003).** `backend.graphql.cursor.key-ids` 를 읽는 곳은
둘 뿐이다: 프로덕션 기동을 거부하는 `GraphQlPlatformStartupValidator` 와 그 값을 돌려주는
`GraphQlPlatformActuatorEndpoint`. **커서에 서명하는 코드는 아무것도 읽지 않는다.** 즉 프로덕션은
키 식별자를 요구하고, 운영자가 넣고, 엔드포인트가 "설정됨"이라고 확인해 주는데, 커서는 validator
메시지가 막는다고 말한 그대로 client-editable 로 남는다. 결함은 "미완성"이 아니라 **startup
validator 가 하나를 완성된 것처럼 보이게 만든다**는 것이다. 닫으려면 배선이 아니라 설계 결정이
필요하다 — `GraphQlCursorKeyRing.of``Map<String, byte[]>` 를 받고 설정 계약은 "키 자체는
설정에 나타나지 않는다"이므로, **키 재료가 어디서 오는지**를 먼저 정해야 한다.
- persisted operation 의 durable 저장 구현체 — 이 leaf 는 중립 계약
`dev.caskeleton.shared.opstore.OperationalRecordStorePort` 에만 의존하고 key/value 매핑만
소유한다. Postgres/Redis 구현체는 **그 중립 계약을** 구현하며, 이 leaf 의 타입을 구현하지
+6 -1
View File
@@ -187,12 +187,17 @@ Closure<String> renderGraphQlApiSurface = {
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
// The approval flag is read at configuration time and carried in, not fetched from `project`
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
// incompatible with the configuration cache — which this build will need before it can adopt one.
boolean graphQlApiSurfaceUpdateApproved = project.hasProperty('approveGraphQlApiSurfaceChange')
tasks.register('verifyGraphQlApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (project.hasProperty('approveGraphQlApiSurfaceChange')) {
if (graphQlApiSurfaceUpdateApproved) {
throw new GradleException(
'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' +
'approved change.')
@@ -53,11 +53,11 @@ public record GraphQlAdvancedPromotionDecision(
/**
* Verifies a promotion is supported by evidence.
*
* @throws GraphQlAdvancedReleaseFailure when evidence is still outstanding
* @throws GraphQlAdvancedReleaseException when evidence is still outstanding
*/
public void verify() {
if (promotes() && !missingEvidence.isEmpty()) {
throw new GraphQlAdvancedReleaseFailure(
throw new GraphQlAdvancedReleaseException(
capability.name() + " cannot be promoted with outstanding evidence: " + missingEvidence);
}
}
@@ -5,7 +5,7 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.release;
*
* <p>Names what is missing, so the gate is a checklist rather than a wall.
*/
public class GraphQlAdvancedReleaseFailure extends RuntimeException {
public class GraphQlAdvancedReleaseException extends RuntimeException {
private static final long serialVersionUID = 1L;
@@ -14,7 +14,7 @@ public class GraphQlAdvancedReleaseFailure extends RuntimeException {
*
* @param reason which evidence is missing
*/
public GraphQlAdvancedReleaseFailure(String reason) {
public GraphQlAdvancedReleaseException(String reason) {
super(reason);
}
}
@@ -15,15 +15,15 @@ public final class GraphQlAdvancedReleaseGate {
/**
* Verifies an Advanced release.
*
* @throws GraphQlAdvancedReleaseFailure naming the missing evidence
* @throws GraphQlAdvancedReleaseException naming the missing evidence
*/
public void verify(GraphQlAdvancedReleaseEvidence evidence) {
if (!evidence.stableBaselinePassed()) {
throw new GraphQlAdvancedReleaseFailure("stable graphql baseline must pass first");
throw new GraphQlAdvancedReleaseException("stable graphql baseline must pass first");
}
List<String> missing = missing(evidence);
if (!missing.isEmpty()) {
throw new GraphQlAdvancedReleaseFailure("advanced graphql evidence incomplete: " + missing);
throw new GraphQlAdvancedReleaseException("advanced graphql evidence incomplete: " + missing);
}
}
@@ -34,12 +34,12 @@ public final class GraphQlAdvancedRunbookIndex {
/**
* Requires a capability to have a runbook.
*
* @throws GraphQlAdvancedReleaseFailure when it does not
* @throws GraphQlAdvancedReleaseException when it does not
*/
public String require(GraphQlAdvancedCapability capability) {
String reference = runbooks.get(capability);
if (reference == null) {
throw new GraphQlAdvancedReleaseFailure(
throw new GraphQlAdvancedReleaseException(
capability.name() + " cannot be released without an operational runbook");
}
return reference;
@@ -61,11 +61,11 @@ public final class GraphQlWebSocketAdmission {
/**
* Verifies the sub-protocol a client negotiated.
*
* @throws GraphQlWebSocketProtocolError when the client asked for an unsupported protocol
* @throws GraphQlWebSocketProtocolException when the client asked for an unsupported protocol
*/
public void requireSupportedSubProtocol(String requested) {
if (!supportedSubProtocols().contains(requested)) {
throw new GraphQlWebSocketProtocolError("unsupported GraphQL WebSocket sub-protocol");
throw new GraphQlWebSocketProtocolException("unsupported GraphQL WebSocket sub-protocol");
}
}
}
@@ -54,14 +54,14 @@ public final class GraphQlWebSocketLifecycle {
/**
* Handles {@code connection_init}.
*
* @throws GraphQlWebSocketProtocolError when it arrives late or twice
* @throws GraphQlWebSocketProtocolException when it arrives late or twice
*/
public void onConnectionInit(Instant now) {
if (now.isAfter(connectedAt.plus(initTimeout))) {
throw new GraphQlWebSocketProtocolError("connection_init timeout");
throw new GraphQlWebSocketProtocolException("connection_init timeout");
}
if (initialised) {
throw new GraphQlWebSocketProtocolError("connection_init sent twice");
throw new GraphQlWebSocketProtocolException("connection_init sent twice");
}
initialised = true;
}
@@ -69,18 +69,19 @@ public final class GraphQlWebSocketLifecycle {
/**
* Handles {@code subscribe}.
*
* @throws GraphQlWebSocketProtocolError before initialisation, past the limit, or while draining
* @throws GraphQlWebSocketProtocolException before initialisation, past the limit, or while
* draining
*/
public void onSubscribe() {
if (!initialised) {
throw new GraphQlWebSocketProtocolError("subscribe before connection_init");
throw new GraphQlWebSocketProtocolException("subscribe before connection_init");
}
if (draining) {
throw new GraphQlWebSocketProtocolError(
throw new GraphQlWebSocketProtocolException(
"server is draining and accepts no new subscriptions");
}
if (subscriptions >= maximumSubscriptions) {
throw new GraphQlWebSocketProtocolError("maximum subscriptions per connection exceeded");
throw new GraphQlWebSocketProtocolException("maximum subscriptions per connection exceeded");
}
subscriptions++;
}
@@ -5,7 +5,7 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.websocket;
*
* <p>Carries no {@code connection_init} payload: that payload is where credentials arrive.
*/
public class GraphQlWebSocketProtocolError extends RuntimeException {
public class GraphQlWebSocketProtocolException extends RuntimeException {
private static final long serialVersionUID = 1L;
@@ -14,7 +14,7 @@ public class GraphQlWebSocketProtocolError extends RuntimeException {
*
* @param reason bounded description of the violation
*/
public GraphQlWebSocketProtocolError(String reason) {
public GraphQlWebSocketProtocolException(String reason) {
super(reason);
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
/**
* Everything about {@code backend.graphql} that has to be settled before a bean exists.
*
* <p>An environment post-processor rather than a bean, for the same reason the master switch uses
* one: these are the failures that have to arrive first, naming what an operator set, instead of
* surfacing as a bean somewhere downstream that could not be assembled because of it.
*
* <p>Two jobs, both scoped to the master switch being on:
*
* <ol>
* <li><b>Refuse a retired safety key.</b> See {@link GraphQlRetiredSafetyAxis}.
* <li><b>Give the framework's console flags the platform's defaults.</b> Spring Boot answers
* introspection by default; this platform does not. Both are defensible on their own and
* together they meant that turning GraphQL on failed at startup on a contradiction nobody had
* configured — the platform said introspection was off, the framework said it was on, and the
* runtime validator correctly refused a deployment with two answers to one question. The
* platform's value is contributed at the lowest precedence, so an operator who sets either
* key still wins and still gets validated; what is removed is the disagreement that existed
* with nothing set at all.
* </ol>
*
* <p>Ordered last so profile-specific configuration data has already been contributed. A check that
* ran earlier would read {@code application-prod.yml}'s keys as absent, which is the same as not
* checking at all for the deployments that matter most.
*/
public class GraphQlActivationEnvironmentPostProcessor
implements EnvironmentPostProcessor, Ordered {
/** Framework key to the platform key it defaults from. */
private static final Map<String, String> FRAMEWORK_DEFAULTS =
Map.of(
"spring.graphql.schema.introspection.enabled",
"backend.graphql.console.introspection-enabled",
"spring.graphql.graphiql.enabled", "backend.graphql.console.graphiql-enabled");
private static final String SOURCE_NAME = "graphQlPlatformConsoleDefaults";
@Override
public void postProcessEnvironment(
ConfigurableEnvironment environment, SpringApplication application) {
List<String> problems = GraphQlRetiredSafetyAxis.problems(environment);
if (!problems.isEmpty()) {
throw new IllegalStateException(String.join(System.lineSeparator(), problems));
}
if (!"true".equalsIgnoreCase(environment.getProperty("backend.graphql.enabled", "false"))) {
return;
}
Map<String, Object> defaults = new LinkedHashMap<>();
FRAMEWORK_DEFAULTS.forEach(
(frameworkKey, platformKey) -> {
if (!environment.containsProperty(frameworkKey)) {
defaults.put(frameworkKey, environment.getProperty(platformKey, "false"));
}
});
if (!defaults.isEmpty()) {
environment.getPropertySources().addLast(new MapPropertySource(SOURCE_NAME, defaults));
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
/**
* The one axis that decides how safely this deployment behaves (design §17, GQL-INT-002).
*
* <p>This used to be two settings. A {@code production} boolean drove anonymous principal handling,
* allow-by-default authorization and part of request protection; an {@code environment} enum drove
* introspection and GraphiQL. They defaulted to {@code false} and {@code PRODUCTION_PUBLIC}
* respectively, in the same record, so the shipped default described an internet-facing production
* endpoint whose protections behaved as if it were a laptop. Two axes that can disagree about the
* same question are not two settings; they are one setting and a bug.
*
* <p>Introspection is a convenience in development and an attack aid in production: it hands an
* anonymous caller the full type system, including the fields nobody advertises. GraphiQL
* additionally serves a query console. Neither substitutes for authorization, and neither belongs
* on a public production endpoint.
*
* <p>There is deliberately no default and no lenient parse. An unset mode is a startup error, not a
* guess: a guess that lands on development is an unauthorized endpoint, and a guess that lands on
* production is an outage an operator cannot explain.
*
* <p>These four are exactly the values a shipped runtime can select — one per runtime profile, with
* production split by exposure. A {@code TEST} and a {@code STAGING} constant used to sit alongside
* them with no profile that could reach either, which is a posture nobody can deploy and nobody can
* test; {@code GraphQlDeploymentModeRegistryParityTest} keeps this list and the operator-facing
* registry from drifting apart again.
*/
public enum GraphQlDeploymentMode {
/** Developer machine. */
LOCAL(true, true, false),
/** Shared development environment. */
DEV(true, true, false),
/** Production behind an organisational boundary. */
PRODUCTION_INTERNAL(true, false, true),
/** Internet-facing production. */
PRODUCTION_PUBLIC(false, false, true);
/** What an operator has to set, and where, when the mode is missing. */
public static final String REQUIRED_MESSAGE =
"backend.graphql.deployment-mode is required while backend.graphql.enabled=true; set "
+ "APP_GRAPHQL_DEPLOYMENT_MODE to one of LOCAL, DEV, PRODUCTION_INTERNAL, "
+ "PRODUCTION_PUBLIC";
private final boolean introspectionAllowed;
private final boolean graphiqlAllowed;
private final boolean production;
GraphQlDeploymentMode(boolean introspectionAllowed, boolean graphiqlAllowed, boolean production) {
this.introspectionAllowed = introspectionAllowed;
this.graphiqlAllowed = graphiqlAllowed;
this.production = production;
}
/** Whether introspection may be enabled at all in this mode. */
public boolean introspectionAllowed() {
return introspectionAllowed;
}
/** Whether GraphiQL may be served. */
public boolean graphiqlAllowed() {
return graphiqlAllowed;
}
/** Whether production rules apply. */
public boolean production() {
return production;
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import java.util.Set;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
/**
* Keeps Spring GraphQL's own auto-configurations out of the candidate set while the master is off.
*
* <p>Conditioning this repository's configuration is not enough. The GraphQL starter contributes
* its auto-configurations through Boot's import metadata, so an ordinary
* {@code @EnableAutoConfiguration} application publishes {@code /graphql} from the classpath alone,
* whatever any project condition says. That is the difference between an endpoint that is off and
* one whose project beans are absent while the framework serves it anyway.
*
* <p>A misspelled entry fails open silently — the filter simply never matches — so the test that
* protects this asserts a 404 on the real port rather than checking what this method returns.
*/
public final class GraphQlOffAutoConfigurationImportFilter
implements AutoConfigurationImportFilter, EnvironmentAware {
private static final String ENABLE_PROPERTY = "backend.graphql.enabled";
private static final Set<String> GRAPHQL_AUTO_CONFIGURATIONS =
Set.of(
"org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.observation.GraphQlObservationAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.data.GraphQlQuerydslAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.data.GraphQlReactiveQuerydslAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.rsocket.GraphQlRSocketAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.rsocket.RSocketGraphQlClientAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.reactive.GraphQlWebFluxAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.reactive.GraphQlWebFluxSecurityAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.servlet.GraphQlWebMvcAutoConfiguration",
"org.springframework.boot.graphql.autoconfigure.servlet.GraphQlWebMvcSecurityAutoConfiguration");
private Environment environment;
@Override
public boolean[] match(String[] candidates, AutoConfigurationMetadata metadata) {
boolean enabled =
environment != null
&& "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false"));
boolean[] matches = new boolean[candidates.length];
for (int index = 0; index < candidates.length; index++) {
matches[index] =
enabled
|| candidates[index] == null
|| !GRAPHQL_AUTO_CONFIGURATIONS.contains(candidates[index]);
}
return matches;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
@@ -20,7 +20,7 @@ public final class GraphQlPlatformActuatorEndpoint {
/** Endpoint id a composition root should register this under. */
public static final String ENDPOINT_ID = "graphqlPlatform";
private final GraphQlPlatformProperties properties;
private final GraphQlPlatformSettings properties;
private final GraphQlSchemaHash schemaHash;
private final Set<String> supportedCapabilities;
private final int registeredOperations;
@@ -36,7 +36,7 @@ public final class GraphQlPlatformActuatorEndpoint {
* @param registeredFetchProfiles count of registered fetch profiles
*/
public GraphQlPlatformActuatorEndpoint(
GraphQlPlatformProperties properties,
GraphQlPlatformSettings properties,
GraphQlSchemaHash schemaHash,
Set<String> supportedCapabilities,
int registeredOperations,
@@ -56,7 +56,7 @@ public final class GraphQlPlatformActuatorEndpoint {
return new GraphQlPlatformConfigurationReport(
schemaHash.value(),
properties.executionProfile().name(),
properties.environment().name(),
properties.deploymentMode() == null ? "UNSET" : properties.deploymentMode().name(),
GraphQlHttpProfile.V1.name(),
supportedCapabilities,
properties.cursor().keyIds(),
@@ -48,12 +48,9 @@ import java.time.Clock;
import java.util.List;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration;
import org.springframework.boot.graphql.autoconfigure.GraphQlProperties;
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -81,8 +78,7 @@ import org.springframework.util.ClassUtils;
* adopter's beans existed and silently failed to back off. Without the ordering, this would race
* the framework's own {@code GraphQlSource} and schema beans.
*/
@AutoConfiguration(after = GraphQlAutoConfiguration.class)
@EnableConfigurationProperties(GraphQlPlatformProperties.class)
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
public class GraphQlPlatformAutoConfiguration {
/** Client profile applied to a caller with no verified credential. */
@@ -118,7 +114,7 @@ public class GraphQlPlatformAutoConfiguration {
*/
@Bean
public InitializingBean graphQlPlatformConfigurationCheck(
GraphQlPlatformProperties properties,
GraphQlPlatformSettings properties,
GraphQlPlatformStartupValidator validator,
GraphQlExecutionPipeline pipeline,
GraphQlScalarWiringConfigurer scalarWiring,
@@ -276,9 +272,18 @@ public class GraphQlPlatformAutoConfiguration {
return chain.pipeline();
}
/** The clock every deadline and expiry check reads. */
/**
* The clock every deadline and expiry check reads, supplied only if the application has none.
*
* <p>Conditioned on the type rather than on this bean's name. Conditioned on the name it always
* created one, so an application that already had a {@code Clock} ended up with two and every
* injection point that wanted one failed to start — which is what happened the first time this
* platform shipped in the same context as the rest of the application, and could not have
* happened while it was build-only. An application's own bean wins; that is what
* auto-configuration is for.
*/
@Bean
@ConditionalOnMissingBean(name = "graphQlPlatformClock")
@ConditionalOnMissingBean(Clock.class)
public Clock graphQlPlatformClock() {
return Clock.systemUTC();
}
@@ -292,7 +297,7 @@ public class GraphQlPlatformAutoConfiguration {
*/
@Bean
@ConditionalOnMissingBean
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformProperties properties) {
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformSettings properties) {
return GraphQlClientPolicy.defaults(
properties.limits().maximumPageSize(),
properties.limits().maximumComplexity(),
@@ -326,19 +331,28 @@ public class GraphQlPlatformAutoConfiguration {
* <p>There is no safe default here, so production does not get one. Coordinate rules are
* application knowledge: a deny-by-default skeleton policy would answer nothing and adopters
* would replace it with an allow-all one, while an allow-by-default policy shipped into
* production would be an unauthorized endpoint. So development gets the permissive default that
* lets the schema be explored, and {@code backend.graphql.production=true} refuses to start
* without an explicit policy.
* production would be an unauthorized endpoint. So a development mode gets the permissive default
* that lets the schema be explored, and a production {@code backend.graphql.deployment-mode}
* refuses to start without an explicit policy.
*
* <p>Which mode that is comes from one setting. While the posture was split across a boolean and
* an enum, this bean read only the boolean — so a deployment declaring {@code PRODUCTION_PUBLIC}
* still received the permissive default.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(
GraphQlPlatformProperties properties) {
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(GraphQlPlatformSettings properties) {
if (properties.deploymentMode() == null) {
throw new GraphQlPlatformConfigurationException(
List.of(GraphQlDeploymentMode.REQUIRED_MESSAGE));
}
if (properties.production()) {
throw new GraphQlPlatformConfigurationException(
List.of(
"backend.graphql.production=true requires an explicit GraphQlAuthorizationPolicy "
+ "bean; the platform has no application coordinates to authorize on its own"));
"backend.graphql.deployment-mode="
+ properties.deploymentMode()
+ " requires an explicit GraphQlAuthorizationPolicy bean; the platform has no "
+ "application coordinates to authorize on its own"));
}
return GraphQlAuthorizationPolicy.builder().denyByDefault(false).build();
}
@@ -446,7 +460,7 @@ public class GraphQlPlatformAutoConfiguration {
GraphQlAuthenticationContextFactory contextFactory,
GraphQlClientPolicy clientPolicy,
GraphQlJsonStructurePolicy structurePolicy,
GraphQlPlatformProperties properties,
GraphQlPlatformSettings properties,
Clock graphQlPlatformClock) {
return new GraphQlPlatformWebInterceptor(
principalResolver,
@@ -522,7 +536,7 @@ public class GraphQlPlatformAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GraphQlOperationNameCardinality graphQlOperationNameCardinality(
GraphQlPlatformProperties properties) {
GraphQlPlatformSettings properties) {
return new GraphQlOperationNameCardinality(properties.observedOperationNames());
}
@@ -551,7 +565,7 @@ public class GraphQlPlatformAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GraphQlPreparsedCachePolicy graphQlPreparsedCachePolicy(
GraphQlPlatformProperties properties) {
GraphQlPlatformSettings properties) {
return new GraphQlPreparsedCachePolicy(
properties.limits().preparsedCacheEntries(),
properties.limits().preparsedCacheWeight(),
@@ -579,7 +593,7 @@ public class GraphQlPlatformAutoConfiguration {
public GraphQlSourceBuilderCustomizer graphQlPreparsedDocumentCustomizer(
BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> cache,
ObjectProvider<GraphQlSource> graphQlSource,
GraphQlPlatformProperties properties) {
GraphQlPlatformSettings properties) {
GraphQlPreparsedDocumentAdapter adapter =
new GraphQlPreparsedDocumentAdapter(
cache, () -> schemaContractHash(graphQlSource), properties.validationPolicyVersion());
@@ -14,7 +14,7 @@ import java.util.TreeSet;
*
* @param schemaHash hash of the deployed schema
* @param executionProfile active execution profile
* @param environment active environment
* @param deploymentMode the single safety posture this deployment runs under
* @param httpProfile transport profile version
* @param supportedCapabilities capability names that are active
* @param cursorKeyIds signing key identities, without the keys
@@ -24,7 +24,7 @@ import java.util.TreeSet;
public record GraphQlPlatformConfigurationReport(
String schemaHash,
String executionProfile,
String environment,
String deploymentMode,
String httpProfile,
Set<String> supportedCapabilities,
Set<String> cursorKeyIds,
@@ -44,7 +44,7 @@ public record GraphQlPlatformConfigurationReport(
Map<String, Object> report = new LinkedHashMap<>();
report.put("schemaHash", schemaHash);
report.put("executionProfile", executionProfile);
report.put("environment", environment);
report.put("deploymentMode", deploymentMode);
report.put("httpProfile", httpProfile);
report.put("supportedCapabilities", supportedCapabilities);
report.put("cursorKeyIds", cursorKeyIds);
@@ -1,70 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import java.util.Locale;
/**
* Environment-dependent introspection and GraphiQL policy (design §17).
*
* <p>Introspection is a convenience in development and an attack aid in production: it hands an
* anonymous caller the full type system, including the fields nobody advertises. GraphiQL
* additionally serves a query console. Neither substitutes for authorization, and neither belongs
* on a public production endpoint.
*/
public enum GraphQlPlatformEnvironment {
/** Developer machine. */
LOCAL(true, true, false),
/** Automated test environment. */
TEST(true, false, false),
/** Shared development environment. */
DEV(true, true, false),
/** Pre-production. */
STAGING(true, false, true),
/** Production behind an organisational boundary. */
PRODUCTION_INTERNAL(true, false, true),
/** Internet-facing production. */
PRODUCTION_PUBLIC(false, false, true);
private final boolean introspectionAllowed;
private final boolean graphiqlAllowed;
private final boolean production;
GraphQlPlatformEnvironment(
boolean introspectionAllowed, boolean graphiqlAllowed, boolean production) {
this.introspectionAllowed = introspectionAllowed;
this.graphiqlAllowed = graphiqlAllowed;
this.production = production;
}
/** Whether introspection may be enabled at all in this environment. */
public boolean introspectionAllowed() {
return introspectionAllowed;
}
/** Whether GraphiQL may be served. */
public boolean graphiqlAllowed() {
return graphiqlAllowed;
}
/** Whether production rules apply. */
public boolean production() {
return production;
}
/** Resolves an environment name, defaulting to the strictest interpretation. */
public static GraphQlPlatformEnvironment of(String name) {
if (name == null || name.isBlank()) {
return PRODUCTION_PUBLIC;
}
try {
return valueOf(name.toUpperCase(Locale.ROOT).replace('-', '_'));
} catch (IllegalArgumentException ex) {
return PRODUCTION_PUBLIC;
}
}
}
@@ -25,7 +25,7 @@ import java.util.Objects;
* framework properties are not on the classpath
*/
public record GraphQlPlatformRuntime(
GraphQlPlatformProperties properties,
GraphQlPlatformSettings properties,
GraphQlExecutionPipeline pipeline,
GraphQlScalarWiringConfigurer scalarWiring,
GraphQlClientPolicy clientPolicy,
@@ -20,8 +20,13 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
* or automatic repository exposure should fail at startup with a clear reason silently ignoring
* the setting would leave an operator believing it took effect.
*
* @param production whether production rules apply
* @param environment environment governing introspection and GraphiQL
* <p>{@code deploymentMode} is the exception to the defaults rule: it has none, and null survives
* construction so {@link GraphQlPlatformStartupValidator} can refuse the startup by name. It
* replaced a {@code production} boolean and an {@code environment} enum that defaulted to {@code
* false} and {@code PRODUCTION_PUBLIC} in the same record a default that claimed to be an
* internet-facing production endpoint while every protection reading the boolean behaved otherwise.
*
* @param deploymentMode the single safety posture; required, see {@link GraphQlDeploymentMode}
* @param executionProfile runtime execution profile
* @param console query console and introspection exposure
* @param limits page and cost ceilings
@@ -30,9 +35,8 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
* @param unbridgedBlockingResolvers resolvers that block without an approved bridge
*/
@ConfigurationProperties("backend.graphql")
public record GraphQlPlatformProperties(
@DefaultValue("false") boolean production,
@DefaultValue("PRODUCTION_PUBLIC") GraphQlPlatformEnvironment environment,
public record GraphQlPlatformSettings(
GraphQlDeploymentMode deploymentMode,
@DefaultValue("BLOCKING_MVC") GraphQlExecutionProfile executionProfile,
@DefaultValue Console console,
@DefaultValue Limits limits,
@@ -42,10 +46,10 @@ public record GraphQlPlatformProperties(
@DefaultValue Set<String> observedOperationNames,
@DefaultValue Set<String> unbridgedBlockingResolvers) {
public GraphQlPlatformProperties {
public GraphQlPlatformSettings {
// Belt and braces for programmatic construction: the binder honours @DefaultValue, but this
// record is also built directly in tests and by adopters composing a policy in Java.
environment = environment == null ? GraphQlPlatformEnvironment.PRODUCTION_PUBLIC : environment;
// deploymentMode is deliberately not defaulted here see the class Javadoc.
executionProfile =
executionProfile == null ? GraphQlExecutionProfile.BLOCKING_MVC : executionProfile;
console = console == null ? Console.disabled() : console;
@@ -167,11 +171,22 @@ public record GraphQlPlatformProperties(
}
}
/**
* Whether production rules apply, which is the deployment mode's answer and nothing else.
*
* <p>Fail-closed while the mode is absent. The startup validator refuses that configuration by
* name, so this value is only ever read on the way to that refusal and an unknown posture that
* resolved to development would be an unauthorized endpoint for exactly as long as it took the
* context to notice.
*/
public boolean production() {
return deploymentMode == null || deploymentMode.production();
}
/** Safe production defaults. */
public static GraphQlPlatformProperties productionDefaults() {
return new GraphQlPlatformProperties(
true,
GraphQlPlatformEnvironment.PRODUCTION_INTERNAL,
public static GraphQlPlatformSettings productionDefaults() {
return new GraphQlPlatformSettings(
GraphQlDeploymentMode.PRODUCTION_INTERNAL,
GraphQlExecutionProfile.BLOCKING_MVC,
Console.disabled(),
Limits.defaults(),
@@ -182,21 +197,34 @@ public record GraphQlPlatformProperties(
Set.of());
}
/** Returns a copy running under a different deployment mode. */
public GraphQlPlatformSettings withDeploymentMode(GraphQlDeploymentMode mode) {
return new GraphQlPlatformSettings(
mode,
executionProfile,
console,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
/** Returns a copy with GraphiQL enabled or disabled. */
public GraphQlPlatformProperties withGraphiqlEnabled(boolean enabled) {
public GraphQlPlatformSettings withGraphiqlEnabled(boolean enabled) {
return withConsole(new Console(enabled, console.introspectionEnabled()));
}
/** Returns a copy with introspection enabled or disabled. */
public GraphQlPlatformProperties withIntrospectionEnabled(boolean enabled) {
public GraphQlPlatformSettings withIntrospectionEnabled(boolean enabled) {
return withConsole(new Console(console.graphiqlEnabled(), enabled));
}
/** Returns a copy with a different console policy. */
public GraphQlPlatformProperties withConsole(Console replacement) {
return new GraphQlPlatformProperties(
production,
environment,
public GraphQlPlatformSettings withConsole(Console replacement) {
return new GraphQlPlatformSettings(
deploymentMode,
executionProfile,
replacement,
limits,
@@ -208,10 +236,9 @@ public record GraphQlPlatformProperties(
}
/** Returns a copy with different limits. */
public GraphQlPlatformProperties withLimits(Limits replacement) {
return new GraphQlPlatformProperties(
production,
environment,
public GraphQlPlatformSettings withLimits(Limits replacement) {
return new GraphQlPlatformSettings(
deploymentMode,
executionProfile,
console,
replacement,
@@ -223,10 +250,9 @@ public record GraphQlPlatformProperties(
}
/** Returns a copy with different cursor signing keys. */
public GraphQlPlatformProperties withCursorKeyIds(Set<String> keyIds) {
return new GraphQlPlatformProperties(
production,
environment,
public GraphQlPlatformSettings withCursorKeyIds(Set<String> keyIds) {
return new GraphQlPlatformSettings(
deploymentMode,
executionProfile,
console,
limits,
@@ -238,10 +264,9 @@ public record GraphQlPlatformProperties(
}
/** Returns a copy with one unsupported capability toggled, for startup-validation tests. */
public GraphQlPlatformProperties withUnsupportedCapability(String capability, boolean enabled) {
return new GraphQlPlatformProperties(
production,
environment,
public GraphQlPlatformSettings withUnsupportedCapability(String capability, boolean enabled) {
return new GraphQlPlatformSettings(
deploymentMode,
executionProfile,
console,
limits,
@@ -253,10 +278,9 @@ public record GraphQlPlatformProperties(
}
/** Returns a copy declaring resolvers that block without an approved bridge. */
public GraphQlPlatformProperties withUnbridgedBlockingResolvers(Set<String> coordinates) {
return new GraphQlPlatformProperties(
production,
environment,
public GraphQlPlatformSettings withUnbridgedBlockingResolvers(Set<String> coordinates) {
return new GraphQlPlatformSettings(
deploymentMode,
executionProfile,
console,
limits,
@@ -268,10 +292,9 @@ public record GraphQlPlatformProperties(
}
/** Returns a copy running a different execution profile. */
public GraphQlPlatformProperties withExecutionProfile(GraphQlExecutionProfile profile) {
return new GraphQlPlatformProperties(
production,
environment,
public GraphQlPlatformSettings withExecutionProfile(GraphQlExecutionProfile profile) {
return new GraphQlPlatformSettings(
deploymentMode,
profile,
console,
limits,
@@ -19,7 +19,7 @@ public final class GraphQlPlatformStartupValidator {
*
* @throws GraphQlPlatformConfigurationException listing every problem found
*/
public void validate(GraphQlPlatformProperties properties) {
public void validate(GraphQlPlatformSettings properties) {
List<String> problems = problems(properties);
if (!problems.isEmpty()) {
throw new GraphQlPlatformConfigurationException(problems);
@@ -27,22 +27,24 @@ public final class GraphQlPlatformStartupValidator {
}
/** Every configuration problem, in a deterministic order. */
public List<String> problems(GraphQlPlatformProperties properties) {
public List<String> problems(GraphQlPlatformSettings properties) {
List<String> problems = new ArrayList<>();
// Reported once even when both the production flag and the environment forbid it, so a single
// misconfiguration does not appear as two problems.
if (properties.console().graphiqlEnabled()
&& (properties.production() || !properties.environment().graphiqlAllowed())) {
problems.add(
"GraphiQL must not be enabled in " + properties.environment() + " or in production");
// First and alone. Every check below asks the mode a question, so reporting them against an
// absent mode would bury the one problem an operator can act on under its own consequences.
if (properties.deploymentMode() == null) {
return List.of(GraphQlDeploymentMode.REQUIRED_MESSAGE);
}
if (properties.console().graphiqlEnabled() && !properties.deploymentMode().graphiqlAllowed()) {
problems.add("GraphiQL must not be enabled in " + properties.deploymentMode());
}
if (properties.production() && properties.cursor().keyIds().isEmpty()) {
problems.add("a cursor signing key is required; unsigned cursors are client-editable");
}
if (properties.console().introspectionEnabled()
&& !properties.environment().introspectionAllowed()) {
problems.add("introspection is not permitted in " + properties.environment());
&& !properties.deploymentMode().introspectionAllowed()) {
problems.add("introspection is not permitted in " + properties.deploymentMode());
}
if (properties.limits().maximumPageSize() < 1) {
problems.add("maximum page size must be positive");
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.core.env.Environment;
/**
* The two retired safety keys, refused rather than ignored (GQL-INT-002).
*
* <p>{@code backend.graphql.production} and {@code backend.graphql.environment} are no longer
* record components, and Spring's binder ignores unknown keys by default. Left alone, an operator
* who sets the key they have always set gets a clean startup and a silently different safety
* posture — which is a worse outcome than the split-brain being fixed, because the old
* configuration at least did something.
*
* <p>Checked whether or not the replacement is also set. A deployment carrying both is exactly the
* state that produced the defect: two keys that can disagree about one question, with the winner
* decided by whichever code path happens to read which.
*
* <p>Only checked while the master switch is on. An adapter that is off has no configuration
* surface to be wrong about, and a repository-wide property left over in someone's shell must not
* stop an application that never publishes a GraphQL endpoint.
*/
public final class GraphQlRetiredSafetyAxis {
private static final Map<String, String> RETIRED_KEYS =
Map.of(
"backend.graphql.production",
"it drove anonymous principal handling and allow-by-default authorization while the "
+ "environment enum drove introspection, so the two could disagree",
"backend.graphql.environment",
"it drove introspection and GraphiQL while the production boolean drove authorization, "
+ "so the two could disagree");
private GraphQlRetiredSafetyAxis() {}
/**
* Returns one migration error per retired key that is still set, in a deterministic order.
*
* @param environment the resolved environment
* @return the problems; empty when the master switch is off or no retired key is present
*/
public static List<String> problems(Environment environment) {
if (!"true".equalsIgnoreCase(environment.getProperty("backend.graphql.enabled", "false"))) {
return List.of();
}
List<String> problems = new ArrayList<>();
for (String key : RETIRED_KEYS.keySet().stream().sorted().toList()) {
if (environment.containsProperty(key)) {
problems.add(
key
+ " is retired ("
+ RETIRED_KEYS.get(key)
+ "). Remove it and set APP_GRAPHQL_DEPLOYMENT_MODE "
+ "(backend.graphql.deployment-mode) to one of LOCAL, DEV, PRODUCTION_INTERNAL, "
+ "PRODUCTION_PUBLIC.");
}
}
return List.copyOf(problems);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Import;
/**
* The one place that decides whether this application exposes a GraphQL endpoint.
*
* <p>The platform configuration this imports carried no master condition of any kind, so putting
* the leaf on the classpath would have activated it — which is why the leaf was kept out of the
* shipped jar entirely, and why "off" and "not built" had become the same thing.
*
* <p>An inbound adapter's off contract is not only about beans. Spring GraphQL publishes {@code
* /graphql} from its own auto-configuration, with no project bean involved, so the companion import
* filter has to keep those candidates out of the set as well. A bean-inventory assertion cannot see
* a route the framework published.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(GraphQlPlatformSettings.class)
@Import(GraphQlPlatformAutoConfiguration.class)
public class GraphQlRootAutoConfiguration {}
@@ -81,7 +81,7 @@ public final class GraphQlDocumentComplexityScorer {
* @param operation the selected operation
* @param variables the request variables, used to resolve page sizes
* @throws GraphQlComplexityRejectedException when a requested page exceeds the maximum
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
* @throws GraphQlStructuralLimitException when traversal exceeds the node budget
*/
public GraphQlComplexityResult score(
GraphQLSchema schema,
@@ -288,7 +288,7 @@ public final class GraphQlDocumentComplexityScorer {
void visit(int budget) {
if (++visited > budget) {
throw GraphQlStructuralLimitViolation.of("COMPLEXITY_TRAVERSAL", visited, budget);
throw GraphQlStructuralLimitException.of("COMPLEXITY_TRAVERSAL", visited, budget);
}
}
}
@@ -71,7 +71,7 @@ public final class GraphQlDocumentShapeAnalyzer {
* measurement: summing operations the request will not run charges a client for a document it
* only sent one part of, and — worse in the other direction — averages away the one that matters.
*
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
* @throws GraphQlStructuralLimitException when traversal exceeds the node budget
*/
public GraphQlDocumentShape analyze(Document document) {
return analyze(document, null);
@@ -82,7 +82,7 @@ public final class GraphQlDocumentShapeAnalyzer {
*
* @param document the parsed document
* @param operation the selected operation, or {@code null} to measure every operation
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
* @throws GraphQlStructuralLimitException when traversal exceeds the node budget
*/
public GraphQlDocumentShape analyze(Document document, OperationDefinition operation) {
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
@@ -158,7 +158,7 @@ public final class GraphQlDocumentShapeAnalyzer {
/**
* Rejects introspection when the client profile does not allow it.
*
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
* @throws GraphQlStructuralLimitException when introspection is selected but not permitted
*/
public void verifyIntrospection(Document document, boolean introspectionAllowed) {
verifyIntrospection(document, null, introspectionAllowed);
@@ -167,12 +167,12 @@ public final class GraphQlDocumentShapeAnalyzer {
/**
* Rejects introspection reached by the selected operation.
*
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
* @throws GraphQlStructuralLimitException when introspection is selected but not permitted
*/
public void verifyIntrospection(
Document document, OperationDefinition operation, boolean introspectionAllowed) {
if (!introspectionAllowed && selectsIntrospection(document, operation)) {
throw GraphQlStructuralLimitViolation.of("INTROSPECTION", 1, 0);
throw GraphQlStructuralLimitException.of("INTROSPECTION", 1, 0);
}
}
@@ -316,7 +316,7 @@ public final class GraphQlDocumentShapeAnalyzer {
void visit(int budget) {
if (++visited > budget) {
throw GraphQlStructuralLimitViolation.of("DOCUMENT_TRAVERSAL", visited, budget);
throw GraphQlStructuralLimitException.of("DOCUMENT_TRAVERSAL", visited, budget);
}
}
}
@@ -6,7 +6,7 @@ package dev.caskeleton.adapter.inbound.graphql.cost;
* <p>Names the dimension and the two counts so the client can see which budget was hit, without the
* document appearing anywhere.
*/
public class GraphQlStructuralLimitViolation extends RuntimeException {
public class GraphQlStructuralLimitException extends RuntimeException {
private static final long serialVersionUID = 1L;
@@ -15,7 +15,7 @@ public class GraphQlStructuralLimitViolation extends RuntimeException {
private final transient String limitName;
private GraphQlStructuralLimitViolation(String limitName, long observed, long allowed) {
private GraphQlStructuralLimitException(String limitName, long observed, long allowed) {
super(CODE + " " + limitName + ": " + observed + " > " + allowed);
this.limitName = limitName;
}
@@ -27,8 +27,8 @@ public class GraphQlStructuralLimitViolation extends RuntimeException {
* @param observed measured count
* @param allowed configured ceiling
*/
public static GraphQlStructuralLimitViolation of(String limitName, long observed, long allowed) {
return new GraphQlStructuralLimitViolation(limitName, observed, allowed);
public static GraphQlStructuralLimitException of(String limitName, long observed, long allowed) {
return new GraphQlStructuralLimitException(limitName, observed, allowed);
}
/** The exceeded dimension's name. */
@@ -29,32 +29,32 @@ public final class GraphQlStructuralLimitPolicy {
/**
* Verifies a measured document shape.
*
* @throws GraphQlStructuralLimitViolation on the first exceeded dimension
* @throws GraphQlStructuralLimitException on the first exceeded dimension
*/
public void verify(GraphQlDocumentShape shape) {
if (shape.aliasCount() > limits.maxAliases()) {
throw GraphQlStructuralLimitViolation.of("ALIASES", shape.aliasCount(), limits.maxAliases());
throw GraphQlStructuralLimitException.of("ALIASES", shape.aliasCount(), limits.maxAliases());
}
if (shape.depth() > limits.maxDepth()) {
throw GraphQlStructuralLimitViolation.of("DEPTH", shape.depth(), limits.maxDepth());
throw GraphQlStructuralLimitException.of("DEPTH", shape.depth(), limits.maxDepth());
}
if (shape.fieldCount() > limits.maxFields()) {
throw GraphQlStructuralLimitViolation.of("FIELDS", shape.fieldCount(), limits.maxFields());
throw GraphQlStructuralLimitException.of("FIELDS", shape.fieldCount(), limits.maxFields());
}
if (shape.fragmentCount() > limits.maxFragments()) {
throw GraphQlStructuralLimitViolation.of(
throw GraphQlStructuralLimitException.of(
"FRAGMENTS", shape.fragmentCount(), limits.maxFragments());
}
if (shape.fragmentSpreadCount() > limits.maxFragmentSpreads()) {
throw GraphQlStructuralLimitViolation.of(
throw GraphQlStructuralLimitException.of(
"FRAGMENT_SPREADS", shape.fragmentSpreadCount(), limits.maxFragmentSpreads());
}
if (shape.operationCount() > limits.maxOperations()) {
throw GraphQlStructuralLimitViolation.of(
throw GraphQlStructuralLimitException.of(
"OPERATIONS", shape.operationCount(), limits.maxOperations());
}
if (shape.inputNestingDepth() > limits.maxInputNestingDepth()) {
throw GraphQlStructuralLimitViolation.of(
throw GraphQlStructuralLimitException.of(
"INPUT_NESTING_DEPTH", shape.inputNestingDepth(), limits.maxInputNestingDepth());
}
}
@@ -5,7 +5,7 @@ package dev.caskeleton.adapter.inbound.graphql.release;
*
* <p>Names the missing evidence, so the answer to "why is this blocked" is in the message.
*/
public class GraphQlReleaseFailure extends RuntimeException {
public class GraphQlReleaseException extends RuntimeException {
private static final long serialVersionUID = 1L;
@@ -14,7 +14,7 @@ public class GraphQlReleaseFailure extends RuntimeException {
*
* @param message which evidence is missing
*/
public GraphQlReleaseFailure(String message) {
public GraphQlReleaseException(String message) {
super(message);
}
}
@@ -16,12 +16,12 @@ public final class GraphQlReleaseGate {
/**
* Verifies a release.
*
* @throws GraphQlReleaseFailure naming the missing evidence
* @throws GraphQlReleaseException naming the missing evidence
*/
public void verify(GraphQlReleaseEvidence evidence) {
List<String> missing = missing(evidence);
if (!missing.isEmpty()) {
throw new GraphQlReleaseFailure(
throw new GraphQlReleaseException(
"schema, contract, performance, fault and compatibility evidence are all required; missing: "
+ missing);
}
@@ -54,7 +54,7 @@ public final class GraphQlReleaseGate {
* @param evidence the collected evidence
* @param override the audited override, or {@code null}
* @param now current instant, used to reject an expired override
* @throws GraphQlReleaseFailure when evidence is missing and no valid override covers it
* @throws GraphQlReleaseException when evidence is missing and no valid override covers it
*/
public void verify(
GraphQlReleaseEvidence evidence, GraphQlReleaseOverride override, Instant now) {
@@ -67,11 +67,11 @@ public final class GraphQlReleaseGate {
return;
}
if (!override.valid(now)) {
throw new GraphQlReleaseFailure(
throw new GraphQlReleaseException(
"the release override has expired; missing evidence: " + missing);
}
if (!override.covers(missing)) {
throw new GraphQlReleaseFailure(
throw new GraphQlReleaseException(
"the release override does not cover the missing evidence: " + missing);
}
}
@@ -73,19 +73,19 @@ public final class GraphQlStableCapabilityManifest {
/**
* Verifies a capability may be activated on the Stable starter.
*
* @throws GraphQlReleaseFailure when it is Advanced, Experimental or unsupported
* @throws GraphQlReleaseException when it is Advanced, Experimental or unsupported
*/
public static void requireStable(String capability) {
if (STABLE.contains(capability)) {
return;
}
if (UNSUPPORTED.contains(capability)) {
throw new GraphQlReleaseFailure(capability + " is not supported by the GraphQL platform");
throw new GraphQlReleaseException(capability + " is not supported by the GraphQL platform");
}
if (ADVANCED.contains(capability) || EXPERIMENTAL.contains(capability)) {
throw new GraphQlReleaseFailure(
throw new GraphQlReleaseException(
capability + " requires the Advanced plan and an explicit feature flag");
}
throw new GraphQlReleaseFailure(capability + " is not a registered GraphQL capability");
throw new GraphQlReleaseException(capability + " is not a registered GraphQL capability");
}
}
@@ -1,7 +1,7 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityRejectedException;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitException;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCategory;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorCode;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext;
@@ -71,10 +71,10 @@ public final class GraphQlPlatformRejectionMapper {
GraphQlErrorCategory.AUTHORIZATION,
context);
}
if (failure instanceof GraphQlStructuralLimitViolation violation) {
if (failure instanceof GraphQlStructuralLimitException violation) {
return GraphQlWireError.of(
"요청 문서가 허용된 구조 한계를 초과했습니다.",
GraphQlErrorCode.of(GraphQlStructuralLimitViolation.CODE),
GraphQlErrorCode.of(GraphQlStructuralLimitException.CODE),
GraphQlErrorCategory.REQUEST,
context)
.withConstraint(violation.limitName());
@@ -0,0 +1,4 @@
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlOffAutoConfigurationImportFilter
org.springframework.boot.EnvironmentPostProcessor=\
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlActivationEnvironmentPostProcessor
@@ -1 +1 @@
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
@@ -48,6 +48,13 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
classes = GraphqlHttpBoundaryQualificationTest.TestApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
// The transport is off unless a deployment asks for it, so a boundary qualification has to
// ask too. Before the master switch existed this test passed by accident: the endpoint was
// published from the classpath.
"backend.graphql.enabled=true",
// Required alongside the switch: the safety posture has no default, because the defaults it
// used to have were a production enum and a non-production boolean that disagreed.
"backend.graphql.deployment-mode=LOCAL",
"spring.graphql.graphiql.enabled=false",
"spring.graphql.schema.introspection.enabled=false",
"spring.graphql.schema.locations=classpath:graphql-qualification-no-discovery/",
@@ -24,7 +24,7 @@ class GraphQlAdvancedReleaseGateTest {
var evidence = new GraphQlAdvancedReleaseEvidence(false, true, true, true, true);
assertThatThrownBy(() -> new GraphQlAdvancedReleaseGate().verify(evidence))
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
.isInstanceOf(GraphQlAdvancedReleaseException.class)
.hasMessageContaining("stable");
}
@@ -34,7 +34,7 @@ class GraphQlAdvancedReleaseGateTest {
assertThatThrownBy(
() -> gate.verify(new GraphQlAdvancedReleaseEvidence(true, true, true, false, true)))
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
.isInstanceOf(GraphQlAdvancedReleaseException.class)
.hasMessageContaining("soak");
assertThat(gate.missing(new GraphQlAdvancedReleaseEvidence(true, false, false, false, false)))
.containsExactly("capabilityContracts", "security", "soak", "compatibility");
@@ -68,7 +68,7 @@ class GraphQlAdvancedReleaseGateTest {
assertThat(promotion.promotes()).isTrue();
assertThatThrownBy(promotion::verify)
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
.isInstanceOf(GraphQlAdvancedReleaseException.class)
.hasMessageContaining("outstanding evidence");
}
@@ -148,7 +148,7 @@ class GraphQlAdvancedReleaseGateTest {
assertThat(index.require(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION))
.isEqualTo("docs/runbooks/graphql-ws.md");
assertThatThrownBy(() -> index.require(GraphQlAdvancedCapability.FEDERATION_SUBGRAPH))
.isInstanceOf(GraphQlAdvancedReleaseFailure.class)
.isInstanceOf(GraphQlAdvancedReleaseException.class)
.hasMessageContaining("runbook");
}
}
@@ -26,7 +26,7 @@ class GraphQlWebSocketProtocolTest {
var lifecycle = new GraphQlWebSocketLifecycle(Duration.ofSeconds(5), CONNECTED_AT);
assertThatThrownBy(() -> lifecycle.onConnectionInit(Instant.parse("2026-08-12T00:00:06Z")))
.isInstanceOf(GraphQlWebSocketProtocolError.class);
.isInstanceOf(GraphQlWebSocketProtocolException.class);
}
@Test
@@ -38,20 +38,21 @@ class GraphQlWebSocketProtocolTest {
() ->
factory.requireSupportedSubProtocol(
GraphQlWebSocketProtocol.UNSUPPORTED_LEGACY_SUB_PROTOCOL))
.isInstanceOf(GraphQlWebSocketProtocolError.class);
.isInstanceOf(GraphQlWebSocketProtocolException.class);
}
@Test
void subscribeBeforeInitialisationIsAProtocolError() {
var lifecycle = new GraphQlWebSocketLifecycle(Duration.ofSeconds(5), CONNECTED_AT);
assertThatThrownBy(lifecycle::onSubscribe).isInstanceOf(GraphQlWebSocketProtocolError.class);
assertThatThrownBy(lifecycle::onSubscribe)
.isInstanceOf(GraphQlWebSocketProtocolException.class);
lifecycle.onConnectionInit(CONNECTED_AT.plusSeconds(1));
assertThatCode(lifecycle::onSubscribe).doesNotThrowAnyException();
assertThat(lifecycle.activeSubscriptions()).isEqualTo(1);
assertThatThrownBy(() -> lifecycle.onConnectionInit(CONNECTED_AT.plusSeconds(2)))
.isInstanceOf(GraphQlWebSocketProtocolError.class);
.isInstanceOf(GraphQlWebSocketProtocolException.class);
}
@Test
@@ -61,7 +62,7 @@ class GraphQlWebSocketProtocolTest {
lifecycle.onSubscribe();
assertThatThrownBy(lifecycle::onSubscribe)
.isInstanceOf(GraphQlWebSocketProtocolError.class)
.isInstanceOf(GraphQlWebSocketProtocolException.class)
.hasMessageContaining("maximum subscriptions");
lifecycle.onComplete();
assertThatCode(lifecycle::onSubscribe).doesNotThrowAnyException();
@@ -77,7 +78,7 @@ class GraphQlWebSocketProtocolTest {
lifecycle.startDraining();
assertThatThrownBy(lifecycle::onSubscribe)
.isInstanceOf(GraphQlWebSocketProtocolError.class)
.isInstanceOf(GraphQlWebSocketProtocolException.class)
.hasMessageContaining("draining");
}
@@ -0,0 +1,274 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
/**
* One deployment mode decides every production-safety behaviour (GQL-INT-002).
*
* <p>There used to be two axes. {@code backend.graphql.production} defaulted to {@code false} and
* {@code backend.graphql.environment} defaulted to {@code PRODUCTION_PUBLIC}, in the same record,
* so the shipped default was a deployment that called itself internet-facing production while every
* protection that read the boolean behaved as if it were not. That is not a display inconsistency:
* anonymous principal handling, allow-by-default authorization, and part of request protection read
* only the boolean, so the enum could say production while the endpoint answered as development.
*
* <p>The cases below are the contract that replaces it: the mode is required, it is the only source
* of the production posture, and the two retired keys are a migration error rather than a setting
* that is quietly ignored.
*/
class GraphQlDeploymentModeContractTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues("backend.graphql.enabled=true");
@ParameterizedTest
@EnumSource(GraphQlDeploymentMode.class)
@DisplayName("the production posture is the mode's, never a separate flag")
void theProductionPostureIsAlwaysTheModes(GraphQlDeploymentMode mode) {
GraphQlPlatformSettings settings =
GraphQlPlatformSettings.productionDefaults().withDeploymentMode(mode);
assertThat(settings.production())
.as(
"%s declares production=%s; a settings object that could answer otherwise is the "
+ "split-brain this collapse removes",
mode, mode.production())
.isEqualTo(mode.production());
}
@Test
@DisplayName("an absent mode is refused, by name")
void anAbsentModeIsRefusedByName() {
GraphQlPlatformSettings settings =
GraphQlPlatformSettings.productionDefaults().withDeploymentMode(null);
assertThat(new GraphQlPlatformStartupValidator().problems(settings))
.as("a message that does not name the property sends an operator reading")
.anySatisfy(problem -> assertThat(problem).contains("backend.graphql.deployment-mode"));
}
@Test
@DisplayName("an absent mode reads as production until it is set")
void anAbsentModeReadsAsProduction() {
assertThat(GraphQlPlatformSettings.productionDefaults().withDeploymentMode(null).production())
.as("an unknown posture that resolves to development is the failure mode, not the default")
.isTrue();
}
@Test
@DisplayName("the console policy follows the mode")
void theConsolePolicyFollowsTheMode() {
GraphQlPlatformStartupValidator validator = new GraphQlPlatformStartupValidator();
assertThat(
validator.problems(
GraphQlPlatformSettings.productionDefaults()
.withDeploymentMode(GraphQlDeploymentMode.LOCAL)
.withGraphiqlEnabled(true)))
.isEmpty();
assertThat(
validator.problems(
GraphQlPlatformSettings.productionDefaults()
.withDeploymentMode(GraphQlDeploymentMode.PRODUCTION_PUBLIC)
.withGraphiqlEnabled(true)))
.anySatisfy(problem -> assertThat(problem).contains("GraphiQL"));
assertThat(
validator.problems(
GraphQlPlatformSettings.productionDefaults()
.withDeploymentMode(GraphQlDeploymentMode.PRODUCTION_PUBLIC)
.withIntrospectionEnabled(true)))
.anySatisfy(problem -> assertThat(problem).contains("introspection"));
}
@ParameterizedTest
@ValueSource(strings = {"backend.graphql.production", "backend.graphql.environment"})
@DisplayName("a retired safety key is a migration error naming the replacement")
void aRetiredKeyIsAMigrationError(String retired) {
List<String> problems =
GraphQlRetiredSafetyAxis.problems(environmentWith(Map.of(retired, "true")));
assertThat(problems)
.as("silently ignoring it leaves an operator believing a safety setting took effect")
.anySatisfy(problem -> assertThat(problem).contains(retired))
.anySatisfy(problem -> assertThat(problem).contains("APP_GRAPHQL_DEPLOYMENT_MODE"));
}
@Test
@DisplayName("a retired key alongside the new one is still a migration error")
void aRetiredKeyAlongsideTheNewOneIsStillAnError() {
List<String> problems =
GraphQlRetiredSafetyAxis.problems(
environmentWith(
Map.of(
"backend.graphql.deployment-mode", "LOCAL",
"backend.graphql.environment", "PRODUCTION_PUBLIC")));
assertThat(problems)
.as(
"two keys that can disagree is the defect; the new one winning silently is the same bug")
.isNotEmpty();
}
@Test
@DisplayName("with the master off the retired keys are not read at all")
void withTheMasterOffTheNamespaceIsNotValidated() {
ConfigurableEnvironment environment =
environmentWith(Map.of("backend.graphql.production", "true"));
environment.getPropertySources().addFirst(off());
assertThat(GraphQlRetiredSafetyAxis.problems(environment))
.as("an off adapter has no configuration surface to be wrong about")
.isEmpty();
}
@Test
@DisplayName("the context refuses to start when the switch is on and no mode is set")
void theContextRefusesToStartWithoutAMode() {
runner.run(
context ->
assertThat(context)
.hasFailed()
.getFailure()
.rootCause()
.hasMessageContaining("backend.graphql.deployment-mode"));
}
@Test
@DisplayName("a development mode gets the permissive authorization default")
void aDevelopmentModeGetsThePermissiveDefault() {
runner
.withPropertyValues("backend.graphql.deployment-mode=LOCAL")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context)
.as("development exists so the schema can be explored without a coordinate table")
.hasSingleBean(GraphQlAuthorizationPolicy.class);
assertThat(context).hasSingleBean(GraphQlPlatformWebInterceptor.class);
});
}
@ParameterizedTest
@ValueSource(strings = {"PRODUCTION_INTERNAL", "PRODUCTION_PUBLIC"})
@DisplayName("a production mode refuses the permissive default, naming the mode")
void aProductionModeRefusesThePermissiveDefault(String mode) {
runner
.withPropertyValues(
"backend.graphql.deployment-mode=" + mode,
"backend.graphql.cursor.key-ids=cursor-key-1")
.run(
context ->
assertThat(context)
.hasFailed()
.getFailure()
.rootCause()
.hasMessageContaining("requires an explicit GraphQlAuthorizationPolicy"));
}
@Test
@DisplayName("the mode reaches the interceptor that decides what an anonymous caller may do")
void theModeReachesTheRequestPath() {
runner
.withPropertyValues("backend.graphql.deployment-mode=LOCAL")
.run(
context -> {
GraphQlPlatformSettings settings = context.getBean(GraphQlPlatformSettings.class);
assertThat(settings.deploymentMode()).isEqualTo(GraphQlDeploymentMode.LOCAL);
assertThat(settings.production())
.as(
"the interceptor is constructed from this value; when it disagreed with the "
+ "environment enum the endpoint served production traffic under local rules")
.isFalse();
});
}
@Test
@DisplayName("the framework's console flags default to the platform's, not to Boot's")
void theFrameworkConsoleFlagsFollowThePlatform() {
ConfigurableEnvironment environment = environmentWith(Map.of());
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
assertThat(environment.getProperty("spring.graphql.schema.introspection.enabled"))
.as(
"Boot answers introspection by default and this platform does not, so turning GraphQL "
+ "on used to fail the runtime validator on a contradiction nobody had configured")
.isEqualTo("false");
assertThat(environment.getProperty("spring.graphql.graphiql.enabled")).isEqualTo("false");
}
@Test
@DisplayName("an operator who sets a framework flag still wins, and is still validated")
void anExplicitFrameworkFlagIsNotOverwritten() {
ConfigurableEnvironment environment =
environmentWith(Map.of("spring.graphql.schema.introspection.enabled", "true"));
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
assertThat(environment.getProperty("spring.graphql.schema.introspection.enabled"))
.as("the defaults are contributed at the lowest precedence; they never override a choice")
.isEqualTo("true");
}
@Test
@DisplayName("the platform's console setting is what the framework is given")
void thePlatformConsoleSettingReachesTheFramework() {
ConfigurableEnvironment environment =
environmentWith(Map.of("backend.graphql.console.introspection-enabled", "true"));
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
assertThat(environment.getProperty("spring.graphql.schema.introspection.enabled"))
.as("an operator who enables the platform's console must not have to enable it twice")
.isEqualTo("true");
}
@Test
@DisplayName("with the master off no framework default is contributed")
void withTheMasterOffNoFrameworkDefaultIsContributed() {
ConfigurableEnvironment environment = environmentWith(Map.of());
environment.getPropertySources().addFirst(off());
new GraphQlActivationEnvironmentPostProcessor().postProcessEnvironment(environment, null);
assertThat(environment.getPropertySources().contains("graphQlPlatformConsoleDefaults"))
.as("an off adapter contributes nothing, including a default")
.isFalse();
}
private static ConfigurableEnvironment environmentWith(Map<String, String> properties) {
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(source("contract", properties));
environment
.getPropertySources()
.addLast(source("master", Map.of("backend.graphql.enabled", "true")));
return environment;
}
private static MapPropertySource off() {
return source("off", Map.of("backend.graphql.enabled", "false"));
}
private static MapPropertySource source(String name, Map<String, String> properties) {
return new MapPropertySource(name, new LinkedHashMap<>(properties));
}
}
@@ -31,10 +31,21 @@ class GraphQlPlatformAutoConfigurationTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class));
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
// The root owns the master switch, so every case here has to turn it on. That is the
// point of entering through the root rather than the platform configuration: a test that
// could assemble the platform without the switch would be proving something the shipped
// application cannot do.
//
// The deployment mode is here for the same reason: it is required, so a case that omits
// it is testing the missing-mode refusal rather than whatever it meant to test. That
// refusal has its own cases in GraphQlDeploymentModeContractTest. Later property values
// override earlier ones, so a case that needs a different mode simply names it.
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
@Test
void aContextWithNoPlatformPropertiesBootsWithSafeDefaults() {
void aContextWithOnlyTheDeploymentModeSetBootsWithSafeDefaults() {
runner.run(
context -> {
assertThat(context).hasNotFailed();
@@ -42,7 +53,7 @@ class GraphQlPlatformAutoConfigurationTest {
assertThat(context).hasSingleBean(GraphQlPlatformInstrumentation.class);
assertThat(context).hasSingleBean(GraphQlScalarWiringConfigurer.class);
GraphQlPlatformProperties properties = context.getBean(GraphQlPlatformProperties.class);
GraphQlPlatformSettings properties = context.getBean(GraphQlPlatformSettings.class);
assertThat(properties.limits().maximumPageSize()).isEqualTo(100);
assertThat(properties.limits().maximumComplexity()).isEqualTo(10_000);
assertThat(properties.console().graphiqlEnabled()).isFalse();
@@ -71,8 +82,7 @@ class GraphQlPlatformAutoConfigurationTest {
void productionRefusesToStartWithoutAnExplicitAuthorizationPolicy() {
runner
.withPropertyValues(
"backend.graphql.production=true",
"backend.graphql.environment=PRODUCTION_INTERNAL",
"backend.graphql.deployment-mode=PRODUCTION_INTERNAL",
"backend.graphql.cursor.key-ids=cursor-key-1")
.run(
context ->
@@ -129,7 +139,7 @@ class GraphQlPlatformAutoConfigurationTest {
runner
.withPropertyValues(
"backend.graphql.environment=LOCAL",
"backend.graphql.deployment-mode=LOCAL",
"backend.graphql.console.introspection-enabled=true")
.withBean(GraphQlProperties.class, () -> framework)
.run(
@@ -148,7 +158,7 @@ class GraphQlPlatformAutoConfigurationTest {
framework.getGraphiql().setEnabled(true);
runner
.withPropertyValues("backend.graphql.environment=LOCAL")
.withPropertyValues("backend.graphql.deployment-mode=LOCAL")
.withBean(GraphQlProperties.class, () -> framework)
.run(
context ->
@@ -166,7 +176,7 @@ class GraphQlPlatformAutoConfigurationTest {
runner
.withPropertyValues(
"backend.graphql.environment=LOCAL",
"backend.graphql.deployment-mode=LOCAL",
"backend.graphql.console.introspection-enabled=true")
.withBean(GraphQlProperties.class, () -> framework)
.run(context -> assertThat(context).hasNotFailed());
@@ -179,8 +189,12 @@ class GraphQlPlatformAutoConfigurationTest {
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports");
assertThat(imports)
.as("an unregistered auto-configuration never applies to an adopter's context")
.contains(GraphQlPlatformAutoConfiguration.class.getName());
.as(
"the registered entry is the master-gated root, not the platform configuration it "
+ "imports; registering the platform directly is what let a context assemble a "
+ "GraphQL endpoint that no switch had asked for")
.contains(GraphQlRootAutoConfiguration.class.getName())
.doesNotContain(GraphQlPlatformAutoConfiguration.class.getName() + "\n");
}
@Test
@@ -188,8 +202,9 @@ class GraphQlPlatformAutoConfigurationTest {
String metadata = readClasspathResource("META-INF/spring-configuration-metadata.json");
assertThat(metadata)
.contains("backend.graphql.production")
.contains("backend.graphql.environment")
.contains("backend.graphql.deployment-mode")
.doesNotContain("backend.graphql.production")
.doesNotContain("backend.graphql.environment")
.contains("backend.graphql.execution-profile")
.contains("backend.graphql.console.graphiql-enabled")
.contains("backend.graphql.console.introspection-enabled")
@@ -15,7 +15,7 @@ class GraphQlPlatformStartupValidatorTest {
@Test
void productionRejectsGraphiqlAndMissingCursorKey() {
var properties =
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withGraphiqlEnabled(true)
.withCursorKeyIds(Set.of());
@@ -28,14 +28,14 @@ class GraphQlPlatformStartupValidatorTest {
assertThatCode(
() ->
new GraphQlPlatformStartupValidator()
.validate(GraphQlPlatformProperties.productionDefaults()))
.validate(GraphQlPlatformSettings.productionDefaults()))
.doesNotThrowAnyException();
}
@Test
void everyProblemIsReportedAtOnce() {
var properties =
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withGraphiqlEnabled(true)
.withCursorKeyIds(Set.of())
.withUnsupportedCapability("multipart", true);
@@ -49,27 +49,27 @@ class GraphQlPlatformStartupValidatorTest {
assertThat(
validator.problems(
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withUnsupportedCapability("multipart", true)))
.anySatisfy(problem -> assertThat(problem).contains("Fileserver"));
assertThat(
validator.problems(
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withUnsupportedCapability("arrayBatch", true)))
.anySatisfy(problem -> assertThat(problem).contains("array batching"));
assertThat(
validator.problems(
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withUnsupportedCapability("requestWideTransaction", true)))
.anySatisfy(problem -> assertThat(problem).contains("request-wide database transactions"));
assertThat(
validator.problems(
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withUnsupportedCapability("repositoryAutoExposure", true)))
.anySatisfy(problem -> assertThat(problem).contains("automatic repository exposure"));
assertThat(
validator.problems(
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withUnsupportedCapability("responseCache", true)))
.anySatisfy(problem -> assertThat(problem).contains("response caching"));
}
@@ -79,7 +79,7 @@ class GraphQlPlatformStartupValidatorTest {
assertThat(
new GraphQlPlatformStartupValidator()
.problems(
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withUnsupportedCapability("advancedOnStableStarter", true)))
.anySatisfy(problem -> assertThat(problem).contains("Advanced capabilities"));
}
@@ -87,7 +87,7 @@ class GraphQlPlatformStartupValidatorTest {
@Test
void aBlockingResolverWithoutABridgeCannotRunOnTheReactiveProfile() {
var properties =
GraphQlPlatformProperties.productionDefaults()
GraphQlPlatformSettings.productionDefaults()
.withExecutionProfile(GraphQlExecutionProfile.REACTIVE_WEBFLUX)
.withUnbridgedBlockingResolvers(Set.of("Order.total"));
@@ -96,19 +96,17 @@ class GraphQlPlatformStartupValidatorTest {
}
@Test
void introspectionAndGraphiqlFollowTheEnvironmentPolicy() {
assertThat(GraphQlPlatformEnvironment.of("local").graphiqlAllowed()).isTrue();
assertThat(GraphQlPlatformEnvironment.of("staging").graphiqlAllowed()).isFalse();
assertThat(GraphQlPlatformEnvironment.of("production_public").introspectionAllowed()).isFalse();
assertThat(GraphQlPlatformEnvironment.of("unknown-environment"))
.isEqualTo(GraphQlPlatformEnvironment.PRODUCTION_PUBLIC);
void introspectionAndGraphiqlFollowTheDeploymentMode() {
assertThat(GraphQlDeploymentMode.LOCAL.graphiqlAllowed()).isTrue();
assertThat(GraphQlDeploymentMode.PRODUCTION_INTERNAL.graphiqlAllowed()).isFalse();
assertThat(GraphQlDeploymentMode.PRODUCTION_PUBLIC.introspectionAllowed()).isFalse();
}
@Test
void theOperationsReportDisclosesHashesAndCapabilitiesOnly() {
var endpoint =
new GraphQlPlatformActuatorEndpoint(
GraphQlPlatformProperties.productionDefaults(),
GraphQlPlatformSettings.productionDefaults(),
GraphQlSchemaHash.sha256("type Query { ping: String! }"),
Set.of("HTTP_POST", "CURSOR_CONNECTION"),
12,
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.graphql.mutation.GraphQlMutationIdempotencyInterceptor;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorKeyRing;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
/**
* Which policies are on the request path, and which only look as though they are (GQL-INT-003).
*
* <p>Operation selection, authorization and cost budgeting are genuinely wired: they are the three
* handlers of {@code GraphQlExecutionChain}, reached through the platform's {@code
* Instrumentation}, and {@code GraphQlPlatformExecutionPathTest} proves each of them rejects on a
* real random-port request before any resolver runs. This file does not repeat that.
*
* <p>It records the other half, which no test asserted and which reads as working from every angle
* an operator can see:
*
* <p><b>Cursor signing is not on the request path.</b> {@code backend.graphql.cursor.key-ids} is
* consumed in exactly two places — {@link GraphQlPlatformStartupValidator}, which refuses to start
* a production deployment without it, and {@link GraphQlPlatformActuatorEndpoint}, which reports it
* back. Nothing signs a cursor with it. {@code HmacGraphQlCursorCodec} and {@link
* GraphQlCursorKeyRing} exist and are unit-tested; neither is ever constructed by the platform. So
* production demands a key identity, an operator supplies one, the endpoint confirms it is
* configured — and cursors remain exactly as client-editable as they were, which is the thing the
* validator's own message says the key prevents.
*
* <p><b>Mutation idempotency is not on the request path either.</b> {@link
* GraphQlMutationIdempotencyInterceptor} is referenced by no configuration.
*
* <p>Both are honest {@code modelled} capabilities by this leaf's own grading table — the defect is
* not that they are unfinished, it is that a startup validator makes one of them look finished.
* Closing it is a design decision rather than a wiring one: {@link GraphQlCursorKeyRing#of} takes
* {@code Map<String, byte[]>} and the settings deliberately say the keys themselves never appear in
* configuration, so where the key material comes from has to be decided before anything can be
* wired. Until it is, this test fails the moment somebody wires one half without the other.
*/
class GraphQlPolicyRequestPathTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL");
@Test
@DisplayName("the policies that are wired reach the request path through the instrumentation")
void theWiredPoliciesAreOnTheRequestPath() {
runner.run(
context -> {
assertThat(context)
.as(
"Spring for GraphQL picks up every Instrumentation bean; this is what turns the "
+ "policy objects from a catalogue into something a request has to pass")
.hasSingleBean(GraphQlPlatformInstrumentation.class);
});
}
@Test
@DisplayName("a configured cursor key signs nothing, because no codec is ever built")
void aConfiguredCursorKeySignsNothing() {
runner
.withPropertyValues("backend.graphql.cursor.key-ids=cursor-key-1")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(GraphQlPlatformSettings.class).cursor().keyIds())
.as("the operator supplied it and the settings carry it")
.containsExactly("cursor-key-1");
assertThat(context)
.as(
"and nothing signs with it: production refuses to start without a key that "
+ "protects nothing, which is worse than not asking for one")
.doesNotHaveBean(GraphQlCursorCodec.class);
assertThat(context).doesNotHaveBean(GraphQlCursorKeyRing.class);
});
}
@Test
@DisplayName("mutation idempotency is modelled, not applied")
void mutationIdempotencyIsNotApplied() {
runner.run(
context ->
assertThat(context)
.as(
"no configuration references the interceptor, so a repeated mutation is a "
+ "repeated mutation")
.doesNotHaveBean(GraphQlMutationIdempotencyInterceptor.class));
}
@Test
@DisplayName("the startup validator still demands the key it cannot make anyone use")
void theValidatorStillDemandsTheUnusedKey() {
GraphQlPlatformSettings production =
GraphQlPlatformSettings.productionDefaults().withCursorKeyIds(Set.of());
assertThat(new GraphQlPlatformStartupValidator().problems(production))
.as(
"kept, not removed: the demand is right and the missing half is the implementation. "
+ "When cursor signing reaches the request path, the two cases above invert and "
+ "this one stays exactly as it is.")
.anySatisfy(problem -> assertThat(problem).contains("cursor signing key"));
}
}
@@ -64,16 +64,20 @@ class GraphQlTransportNeutralityTest {
@Test
void aNonWebContextAcceptsAnyProfile() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
.withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX")
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.execution-profile=REACTIVE_WEBFLUX")
.run(context -> assertThat(context).hasNotFailed());
}
@Test
void aServletContextRunningTheReactiveProfileRefusesToStart() {
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
.withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX")
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.execution-profile=REACTIVE_WEBFLUX")
.run(
context ->
assertThat(context)
@@ -86,8 +90,10 @@ class GraphQlTransportNeutralityTest {
@Test
void aReactiveContextRunningTheBlockingProfileRefusesToStart() {
new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
.withPropertyValues("backend.graphql.execution-profile=BLOCKING_MVC")
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.execution-profile=BLOCKING_MVC")
.run(
context ->
assertThat(context)
@@ -100,8 +106,10 @@ class GraphQlTransportNeutralityTest {
@Test
void aServletContextRunningTheBlockingProfileStarts() {
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
.withPropertyValues("backend.graphql.execution-profile=BLOCKING_MVC")
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.execution-profile=BLOCKING_MVC")
.run(
context -> {
assertThat(context).hasNotFailed();
@@ -113,8 +121,10 @@ class GraphQlTransportNeutralityTest {
@Test
void aReactiveContextRunningTheReactiveProfileStarts() {
new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
.withPropertyValues("backend.graphql.execution-profile=REACTIVE_WEBFLUX")
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.enabled=true", "backend.graphql.execution-profile=REACTIVE_WEBFLUX")
.run(
context -> {
assertThat(context).hasNotFailed();
@@ -144,7 +154,8 @@ class GraphQlTransportNeutralityTest {
@Test
void aReactiveContextWithUnbridgedBlockingResolversRefusesToStart() {
new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(GraphQlPlatformAutoConfiguration.class))
.withPropertyValues("backend.graphql.enabled=true", "backend.graphql.deployment-mode=LOCAL")
.withConfiguration(AutoConfigurations.of(GraphQlRootAutoConfiguration.class))
.withPropertyValues(
"backend.graphql.execution-profile=MIXED_CONTROLLED",
"backend.graphql.unbridged-blocking-resolvers=Order.total")
@@ -33,7 +33,7 @@ class GraphQlFragmentReachabilityTest {
.as("the fragment reaches __schema, so the document selects introspection")
.isTrue();
assertThatThrownBy(() -> analyzer.verifyIntrospection(document, false))
.isInstanceOf(GraphQlStructuralLimitViolation.class)
.isInstanceOf(GraphQlStructuralLimitException.class)
.hasMessageContaining("INTROSPECTION");
}
@@ -17,7 +17,7 @@ class GraphQlStructuralLimitPolicyTest {
var limits = new GraphQlStructuralLimits(8, 100, 10, 20, 40, 2, 8);
assertThatThrownBy(() -> new GraphQlStructuralLimitPolicy(limits).verify(shape))
.isInstanceOf(GraphQlStructuralLimitViolation.class)
.isInstanceOf(GraphQlStructuralLimitException.class)
.hasMessageContaining("ALIASES");
}
@@ -75,7 +75,7 @@ class GraphQlStructuralLimitPolicyTest {
var analyzer = new GraphQlDocumentShapeAnalyzer(3);
assertThatThrownBy(() -> analyzer.analyze("query Wide { a b c d e f }"))
.isInstanceOf(GraphQlStructuralLimitViolation.class)
.isInstanceOf(GraphQlStructuralLimitException.class)
.hasMessageContaining("DOCUMENT_TRAVERSAL");
}
@@ -100,7 +100,7 @@ class GraphQlStructuralLimitPolicyTest {
assertThat(analyzer.selectsIntrospection(document)).isTrue();
assertThatThrownBy(() -> analyzer.verifyIntrospection(document, false))
.isInstanceOf(GraphQlStructuralLimitViolation.class)
.isInstanceOf(GraphQlStructuralLimitException.class)
.hasMessageContaining("INTROSPECTION");
assertThatCode(() -> analyzer.verifyIntrospection(document, true)).doesNotThrowAnyException();
}
@@ -27,7 +27,7 @@ class GraphQlReleaseGateTest {
.build();
assertThatThrownBy(() -> new GraphQlReleaseGate().verify(evidence))
.isInstanceOf(GraphQlReleaseFailure.class)
.isInstanceOf(GraphQlReleaseException.class)
.hasMessageContaining("fault");
}
@@ -79,10 +79,10 @@ class GraphQlReleaseGateTest {
"release-manager", "incident", Set.of("fault"), NOW.plus(Duration.ofDays(1)));
assertThatThrownBy(() -> gate.verify(incomplete, expired, NOW))
.isInstanceOf(GraphQlReleaseFailure.class)
.isInstanceOf(GraphQlReleaseException.class)
.hasMessageContaining("expired");
assertThatThrownBy(() -> gate.verify(incomplete, wrongScope, NOW))
.isInstanceOf(GraphQlReleaseFailure.class)
.isInstanceOf(GraphQlReleaseException.class)
.hasMessageContaining("does not cover");
assertThatCode(() -> gate.verify(incomplete, valid, NOW)).doesNotThrowAnyException();
}
@@ -115,14 +115,14 @@ class GraphQlReleaseGateTest {
assertThat(GraphQlStableCapabilityManifest.stable("HTTP_POST")).isTrue();
assertThatThrownBy(
() -> GraphQlStableCapabilityManifest.requireStable("GRAPHQL_MULTIPART_UPLOAD"))
.isInstanceOf(GraphQlReleaseFailure.class)
.isInstanceOf(GraphQlReleaseException.class)
.hasMessageContaining("not supported");
assertThatThrownBy(
() -> GraphQlStableCapabilityManifest.requireStable("WEBSOCKET_SUBSCRIPTION"))
.isInstanceOf(GraphQlReleaseFailure.class)
.isInstanceOf(GraphQlReleaseException.class)
.hasMessageContaining("Advanced plan");
assertThatThrownBy(() -> GraphQlStableCapabilityManifest.requireStable("MADE_UP"))
.isInstanceOf(GraphQlReleaseFailure.class);
.isInstanceOf(GraphQlReleaseException.class);
}
@Test
@@ -55,6 +55,8 @@ import org.springframework.stereotype.Controller;
classes = GraphQlPlatformExecutionPathTest.TestApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
// The transport is off unless a deployment asks for it, so an execution-path test has to ask.
"backend.graphql.enabled=true",
"spring.graphql.graphiql.enabled=false",
// The schema permits introspection at the deployment level, and the two flags agree so the
// contradiction check stays quiet. What refuses the query is the anonymous profile's client
@@ -65,7 +67,7 @@ import org.springframework.stereotype.Controller;
"spring.graphql.schema.additional-files="
+ "classpath:graphql/skeleton.graphqls,"
+ "classpath:graphql-platform/platform.graphqls",
"backend.graphql.environment=LOCAL",
"backend.graphql.deployment-mode=LOCAL",
"backend.graphql.console.introspection-enabled=true",
"backend.graphql.limits.maximum-page-size=100",
// Small enough that one nested selection exceeds it, large enough that the allowed queries do
@@ -7,6 +7,7 @@ import org.springframework.aop.Advisor;
import org.springframework.aop.Pointcut;
import org.springframework.aop.support.Pointcuts;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -26,11 +27,30 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
@EnableMethodSecurity(prePostEnabled = false)
public class MethodSecurityConfig {
/**
* The advisor, taking the port as a provider rather than as a value.
*
* <p>An advisor is infrastructure and is created while {@code BeanPostProcessor}s are still being
* registered. Taking {@code AuthorizationPort} directly made Spring instantiate it — and with it
* {@code RolePermissionRegistry} and the bound {@code RolePermissionPolicy} — inside that window,
* and every startup logged three {@code BeanPostProcessorChecker} warnings saying those beans
* were not eligible for post-processing. They were not eligible, which is the actual problem: a
* bean created that early skips the post-processors that would otherwise apply to it.
*
* <p>Deferred, not silenced. Marking the chain {@code ROLE_INFRASTRUCTURE} removes the warning
* and keeps the early instantiation; resolving through {@link ObjectProvider} on first
* authorization instead means the port is built normally, after the container is ready. This is
* the same fix {@code TracingConfig} uses for {@code Tracer}.
*
* @param authorizationPort the port, resolved on first use
* @return the advisor
*/
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
static Advisor requiresPermissionAuthorizationAdvisor(
ObjectProvider<AuthorizationPort> authorizationPort) {
AuthorizationManager<MethodInvocation> manager =
new RequiresPermissionAuthorizationManager(authorizationPort);
new RequiresPermissionAuthorizationManager(authorizationPort::getObject);
Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);
Pointcut onClass = AnnotationMatchingPointcut.forClassAnnotation(RequiresPermission.class);
@@ -29,9 +29,17 @@ import org.springframework.security.core.Authentication;
public final class RequiresPermissionAuthorizationManager
implements AuthorizationManager<MethodInvocation> {
private final AuthorizationPort authorizationPort;
private final Supplier<AuthorizationPort> authorizationPort;
public RequiresPermissionAuthorizationManager(AuthorizationPort authorizationPort) {
/**
* Creates the manager.
*
* @param authorizationPort the port, resolved on first authorization rather than at construction;
* an advisor is built while BeanPostProcessors are still registering, and resolving the port
* there instantiates it — and its whole role/permission chain — too early to be
* post-processed
*/
public RequiresPermissionAuthorizationManager(Supplier<AuthorizationPort> authorizationPort) {
this.authorizationPort = authorizationPort;
}
@@ -49,7 +57,7 @@ public final class RequiresPermissionAuthorizationManager
return new AuthorizationDecision(false);
}
try {
authorizationPort.requirePermission(toPrincipal(auth), required);
authorizationPort.get().requirePermission(toPrincipal(auth), required);
return new AuthorizationDecision(true);
} catch (AuthorizationDeniedException denied) {
return new AuthorizationDecision(false);
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand;
import dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase;
import dev.caskeleton.shared.response.Envelope;
import jakarta.validation.Valid;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Submitting a notification (NTF-INT-008).
*
* <p>The platform's only submission surface. Until this existed it had none: every use case, store,
* worker, template registry and provider was in place, and nothing outside the process could ask it
* to send anything. The callback endpoint was the single notification path a deployment exposed,
* and that one is for providers reporting outcomes back — it cannot originate a notification.
*
* <p>{@code 202}, not {@code 201}. A receipt means the request and its recipient jobs are committed
* and a worker will pick them up; no provider has been contacted while the transaction was open, so
* answering {@code 201 Created} with a location would promise a resource whose delivery has not
* been attempted. The platform's own contract is that acceptance and delivery are different events.
*
* <p>Authenticated like every other non-public path, and authorized by {@code notification:submit}
* on the use case rather than here. A transport that decided its own permissions would be a second
* authorization model to keep in step with the first.
*/
@RestController
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform",
name = "enabled",
havingValue = "true")
@RequestMapping("/notifications")
public class NotificationSubmissionController {
/** What a caller gets when it names no version; the first published version of a template. */
private static final long DEFAULT_TEMPLATE_VERSION = 1L;
/** What a caller gets when it names no category. */
private static final String DEFAULT_CATEGORY = "transactional";
private final AcceptNotificationUseCase accept;
/**
* Creates the controller.
*
* @param accept the use case that registers the contact point and submits
*/
public NotificationSubmissionController(AcceptNotificationUseCase accept) {
this.accept = Objects.requireNonNull(accept, "accept");
}
/**
* Accepts one notification for dispatch.
*
* @param request the submission
* @return {@code 202} with the receipt
*/
@PostMapping
public ResponseEntity<Envelope<SubmitNotificationHttpResponse>> submit(
@Valid @RequestBody SubmitNotificationHttpRequest request) {
AcceptNotificationCommand command =
new AcceptNotificationCommand(
Optional.empty(),
request.recipientRef(),
channel(request.channel()),
request.address(),
request.template(),
request.templateVersion() == null
? DEFAULT_TEMPLATE_VERSION
: request.templateVersion(),
locale(request.locale()),
NotificationVariableCoercion.coerceAll(request.variables()),
Optional.ofNullable(request.idempotencyKey()).filter(key -> !key.isBlank()),
request.category() == null || request.category().isBlank()
? DEFAULT_CATEGORY
: request.category());
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(
Envelope.ok(
SubmitNotificationHttpResponse.from(accept.handle(command)),
ResponseMetaFactory.fromMdc()));
}
/**
* The channel a caller named.
*
* <p>Parsed here rather than bound by Jackson so an unknown name fails as a validation error
* naming the vocabulary, instead of as a deserialization error naming a Java enum.
*/
private static Channel channel(String name) {
try {
return Channel.valueOf(name.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException unknown) {
throw new IllegalArgumentException(
"channel must be one of " + java.util.Arrays.toString(Channel.values()), unknown);
}
}
/**
* The locale a caller named, or the platform default.
*
* <p>{@link Locale#forLanguageTag} answers {@code Locale.ROOT} for anything it cannot parse
* rather than failing, so an unparseable tag is refused explicitly — silently rendering a Korean
* notification in the root locale is the kind of wrong that reaches a recipient.
*/
private static Locale locale(String tag) {
if (tag == null || tag.isBlank()) {
return Locale.getDefault();
}
Locale parsed = Locale.forLanguageTag(tag.trim());
if (parsed.equals(Locale.ROOT)) {
throw new IllegalArgumentException("locale must be a BCP 47 language tag");
}
return parsed;
}
}
@@ -0,0 +1,134 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand;
import dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase;
import dev.caskeleton.application.notification.platform.template.TemplateSlot;
import dev.caskeleton.shared.response.Envelope;
import jakarta.validation.Valid;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Publishing a notification template version (NTF-INT-008).
*
* <p>A submission pins a template id and version, and the platform refuses one it cannot resolve.
* So this endpoint is not a convenience next to {@link NotificationSubmissionController} — without
* it every submission fails, which is what a deployment found: the registry port had {@code
* publish} and no application or transport ever called it.
*
* <p>{@code 201}, unlike a submission's {@code 202}: a published version is an immutable resource
* that exists when the call returns, with nothing deferred to a worker.
*
* <p>Authorized by {@code notification-template:publish}, which is deliberately not {@code
* notification:submit}. Publishing a template changes what every future submission renders, and a
* service that may send notifications is not thereby a service that may rewrite them.
*/
@RestController
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform",
name = "enabled",
havingValue = "true")
@RequestMapping("/notification-templates")
public class NotificationTemplateController {
private final PublishNotificationTemplateUseCase publish;
/**
* Creates the controller.
*
* @param publish the publication use case
*/
public NotificationTemplateController(PublishNotificationTemplateUseCase publish) {
this.publish = Objects.requireNonNull(publish, "publish");
}
/**
* Publishes one immutable template version.
*
* @param request the version to publish
* @return {@code 201} with the published version and the digest the platform computed
*/
@PostMapping
public ResponseEntity<Envelope<PublishTemplateHttpResponse>> publish(
@Valid @RequestBody PublishTemplateHttpRequest request) {
PublishNotificationTemplateCommand command =
new PublishNotificationTemplateCommand(
request.templateId(),
request.version() == null ? 1L : request.version(),
channel(request.channel()),
locale(request.locale()),
Optional.ofNullable(request.fallbackLocale())
.filter(tag -> !tag.isBlank())
.map(NotificationTemplateController::locale),
slots(request.slots()),
names(request.requiredVariables()),
names(request.sensitiveVariables()));
return ResponseEntity.status(HttpStatus.CREATED)
.body(
Envelope.ok(
PublishTemplateHttpResponse.from(publish.handle(command)),
ResponseMetaFactory.fromMdc()));
}
/**
* The slots a caller named.
*
* <p>An unknown slot name is refused rather than dropped. Silently ignoring it publishes a
* template missing the content the caller wrote, and the failure then arrives at render time as a
* missing required slot for a template that looks published and complete.
*/
private static Map<TemplateSlot, String> slots(Map<String, String> source) {
Map<TemplateSlot, String> slots = new EnumMap<>(TemplateSlot.class);
source.forEach(
(name, content) -> {
TemplateSlot slot;
try {
slot = TemplateSlot.valueOf(name.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException unknown) {
throw new IllegalArgumentException(
"slot must be one of " + java.util.Arrays.toString(TemplateSlot.values()), unknown);
}
slots.put(slot, content);
});
return slots;
}
private static Set<String> names(List<String> source) {
return source == null ? Set.of() : Set.copyOf(source);
}
private static Channel channel(String name) {
try {
return Channel.valueOf(name.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException unknown) {
throw new IllegalArgumentException(
"channel must be one of " + java.util.Arrays.toString(Channel.values()), unknown);
}
}
private static Locale locale(String tag) {
Locale parsed = Locale.forLanguageTag(tag.trim());
if (parsed.equals(Locale.ROOT)) {
throw new IllegalArgumentException("locale must be a BCP 47 language tag");
}
return parsed;
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import dev.caskeleton.application.notification.platform.api.NotificationVariable;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Turns a parsed JSON value into the platform's closed variable algebra.
*
* <p>The coercion exists because {@code NotificationVariable} is not {@code Object}, and that is a
* deliberate property rather than a formality: the request fingerprint is computed over variables,
* and when they were {@code Object} the fingerprint rendered them through {@code toString()} — so
* {@code "1"} and {@code 1} hashed alike and two different requests could collapse onto one
* idempotency key. Coercing at the transport boundary is what keeps that distinction from depending
* on a JSON parser's choice of box type.
*
* <p>Integral JSON numbers arrive as {@code Integer} or {@code Long} and floating point ones as
* {@code Double}; all three become {@link BigDecimal} through their decimal text, never through
* {@code BigDecimal.valueOf(double)}, which would turn {@code 0.1} into its binary approximation
* and make the fingerprint depend on IEEE 754 rather than on what the caller wrote.
*
* <p>An unsupported type is refused rather than stringified. A silent {@code toString()} is how a
* caller's nested structure would become the literal text of a Java object reference inside a
* rendered notification.
*/
final class NotificationVariableCoercion {
private NotificationVariableCoercion() {}
/**
* Coerces a parsed JSON object into platform variables.
*
* @param source the parsed body's variable map, possibly null
* @return the variables, empty when the caller supplied none
*/
static Map<String, NotificationVariable> coerceAll(Map<String, Object> source) {
if (source == null || source.isEmpty()) {
return Map.of();
}
Map<String, NotificationVariable> coerced = new LinkedHashMap<>(source.size());
source.forEach((name, value) -> coerced.put(name, coerce(name, value)));
return Map.copyOf(coerced);
}
private static NotificationVariable coerce(String name, Object value) {
return switch (value) {
case null -> NotificationVariable.NullValue.INSTANCE;
case String text -> new NotificationVariable.TextValue(text);
case Boolean flag -> new NotificationVariable.BooleanValue(flag);
case BigDecimal number -> new NotificationVariable.NumberValue(number);
case Number number -> new NotificationVariable.NumberValue(new BigDecimal(number.toString()));
case List<?> values -> new NotificationVariable.ListValue(coerceEach(name, values));
case Map<?, ?> values -> new NotificationVariable.ObjectValue(coerceEntries(name, values));
default ->
throw new IllegalArgumentException(
"variable '" + name + "' has a type the platform does not carry");
};
}
private static List<NotificationVariable> coerceEach(String name, List<?> values) {
return values.stream().map(value -> coerce(name, value)).toList();
}
private static Map<String, NotificationVariable> coerceEntries(String name, Map<?, ?> values) {
Map<String, NotificationVariable> coerced = new LinkedHashMap<>(values.size());
values.forEach(
(key, value) -> {
if (!(key instanceof String text)) {
throw new IllegalArgumentException("variable '" + name + "' has a non-string key");
}
coerced.put(text, coerce(name + "." + text, value));
});
return Map.copyOf(coerced);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
import java.util.Map;
/**
* Transport shape of a template publication.
*
* <p>The content digest is absent by design: the platform computes it from the slots. A caller that
* supplies both content and its digest supplies two things that can disagree, and the digest is
* what the platform uses to decide that a published version was never edited.
*
* @param templateId the stable identity a submission pins
* @param version the version pinned alongside it; versions are immutable once published
* @param channel the channel this version renders for
* @param locale the BCP 47 tag this version is written in
* @param fallbackLocale the tag to resolve to when a request asks for one this version lacks
* @param slots the sources by slot name, e.g. {@code SUBJECT}, {@code TEXT_BODY}
* @param requiredVariables variables a submission must supply
* @param sensitiveVariables variables that must be masked in logs, previews and failures
*/
public record PublishTemplateHttpRequest(
@NotBlank String templateId,
@Positive Long version,
@NotBlank String channel,
@NotBlank String locale,
String fallbackLocale,
@NotEmpty Map<String, String> slots,
List<String> requiredVariables,
List<String> sensitiveVariables) {}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import dev.caskeleton.application.notification.platform.template.NotificationTemplateVersion;
/**
* Transport shape of a published template version.
*
* <p>The content is not echoed back. A caller that just sent it does not need it returned, and a
* template body can carry the shape of a message a recipient will see.
*
* @param templateId the published identity
* @param version the published version
* @param channel the channel it renders for
* @param locale the locale it is written in
* @param contentDigest the digest the platform computed over its slots
* @param status its lifecycle state
*/
public record PublishTemplateHttpResponse(
String templateId,
long version,
String channel,
String locale,
String contentDigest,
String status) {
/**
* Maps a published version onto the transport shape.
*
* @param version the published version
* @return the response body
*/
public static PublishTemplateHttpResponse from(NotificationTemplateVersion version) {
return new PublishTemplateHttpResponse(
version.templateId(),
version.version(),
version.channel().name(),
version.locale().toLanguageTag(),
version.contentDigest(),
version.status().name());
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.Map;
/**
* Transport shape of a notification submission.
*
* <p>Deliberately not the application's {@code NotificationPlan}. A plan references contact points
* by stored identity and carries deduplication, collapse and metadata policy; a caller with an
* address and a template has none of that, and exposing the plan over HTTP would make the transport
* contract move whenever the platform's internal one does.
*
* <p>{@code address} is a contact point value. It is validated for presence only — an address
* echoed back in a validation message is an address in a log line, which is what the platform's
* encryption-at-rest design exists to prevent.
*
* @param recipientRef the caller's stable reference for the recipient; never the address
* @param channel the channel name, matching the platform's channel vocabulary
* @param address the contact point value in its channel's textual form
* @param template the template id to pin
* @param templateVersion the version to pin; 1 when the caller omits it
* @param locale the BCP 47 language tag to render in; the platform default when omitted
* @param variables template variables, as JSON scalars, lists and objects
* @param idempotencyKey the caller's key; derived from the request when omitted
* @param category the routing and reporting category; {@code transactional} when omitted
*/
public record SubmitNotificationHttpRequest(
@NotBlank @Size(max = 200) String recipientRef,
@NotBlank String channel,
@NotBlank String address,
@NotBlank String template,
@Positive Long templateVersion,
String locale,
Map<String, Object> variables,
@Size(max = 200) String idempotencyKey,
@Size(max = 120) String category) {}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.submission;
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
import java.time.Instant;
/**
* Transport shape of a receipt.
*
* <p>{@code willDeliver} is here because {@code status} alone is ambiguous to a caller: a request
* that deduplication converged onto an earlier one is accepted and will produce no new delivery,
* and a client that retries on "no delivery" would loop forever against a correct answer.
*
* @param requestId the accepted notification's id
* @param status its request status
* @param acceptedAt when the platform committed it
* @param willDeliver whether this acceptance will produce a delivery of its own
*/
public record SubmitNotificationHttpResponse(
String requestId, String status, Instant acceptedAt, boolean willDeliver) {
/**
* Maps a receipt onto the transport shape.
*
* @param receipt the application receipt
* @return the response body
*/
public static SubmitNotificationHttpResponse from(NotificationReceipt receipt) {
return new SubmitNotificationHttpResponse(
receipt.notificationId().value().toString(),
receipt.status().name(),
receipt.acceptedAt(),
receipt.willDeliver());
}
}
@@ -34,8 +34,13 @@ class RequiresPermissionAuthorizationManagerTest {
"user", List.of("worklog:read", "worklog:write"),
"admin", List.of("worklog:read", "worklog:write", "worklog:close")))));
// A supplier, matching the production wiring: the advisor resolves the port on first use rather
// than at construction, because an advisor is built while BeanPostProcessors are still
// registering
// and resolving it there instantiated the whole role/permission chain too early to be
// post-processed. Passing the instance directly here would test a shape production does not use.
private final RequiresPermissionAuthorizationManager manager =
new RequiresPermissionAuthorizationManager(port);
new RequiresPermissionAuthorizationManager(() -> port);
// --- fixtures: a guarded and an unguarded method ----------------------------------
+1 -1
View File
@@ -28,7 +28,7 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
The Redis wrapper and typed API described in
`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its
design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry
design models the SDK as twelve Gradle modules; this repository's fail-closed registry
outranks that layout, so each designed module is a package instead. Delivery status and the full
adaptation rationale are in
`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
+1 -1
View File
@@ -14,7 +14,7 @@ Package root: `dev.caskeleton.adapter.outbound.httpclient`.
The implementation follows
`httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`.
The design assumes 19 separate Gradle modules; this repository's fail-closed 19-leaf registry
The design assumes 19 separate Gradle modules; this repository's fail-closed registry
outranks that layout, so those modules are **packages** here. The mapping, and every other
deliberate substitution, is recorded in `docs/httpclient/repository-adaptation.md`. Read it before
moving a type between packages.
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.messaging.autoconfigure;
import dev.caskeleton.adapter.outbound.messaging.MessagingConfig;
import dev.caskeleton.adapter.outbound.messaging.MessagingSettings;
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Import;
/**
* The one place that decides whether this application publishes to a broker.
*
* <p>Before this, whether {@code app.messaging.broker} was blank was the de-facto switch. That is a
* selector doing a switch's job, and it reads badly in both directions: a blank broker with the
* relay enabled took down startup, while a deployment that wanted no messaging at all still
* assembled settings, a Kafka adapter configuration and two publishers. The broker id now selects
* <em>which</em> transport, and this switch decides <em>whether</em> there is one.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "app.messaging", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(MessagingSettings.class)
@Import({MessagingConfig.class, KafkaAdapterConfig.class})
public class MessagingBridgeRootAutoConfiguration {}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.messaging.autoconfigure;
import java.util.Set;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
/**
* Keeps Boot's broker auto-configurations out of the candidate set while messaging is off.
*
* <p>The Kafka and AMQP starters contribute theirs through Boot's import metadata, so a client
* library on the classpath is enough to build a connection factory, a template and a listener
* container — none of which any project condition was consulted about. It also means both brokers
* would assemble at once simply because both libraries are present, which is a different bug the
* same filter prevents.
*/
public final class MessagingOffAutoConfigurationImportFilter
implements AutoConfigurationImportFilter, EnvironmentAware {
private static final String ENABLE_PROPERTY = "app.messaging.enabled";
private static final Set<String> BROKER_AUTO_CONFIGURATIONS =
Set.of(
"org.springframework.boot.kafka.autoconfigure.KafkaAutoConfiguration",
"org.springframework.boot.kafka.autoconfigure.metrics.KafkaMetricsAutoConfiguration",
"org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration",
"org.springframework.boot.amqp.autoconfigure.RabbitAnnotationDrivenAutoConfiguration",
"org.springframework.boot.amqp.autoconfigure.health.RabbitHealthContributorAutoConfiguration");
private Environment environment;
@Override
public boolean[] match(String[] candidates, AutoConfigurationMetadata metadata) {
boolean enabled =
environment != null
&& "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false"));
boolean[] matches = new boolean[candidates.length];
for (int index = 0; index < candidates.length; index++) {
matches[index] =
enabled
|| candidates[index] == null
|| !BROKER_AUTO_CONFIGURATIONS.contains(candidates[index]);
}
return matches;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingOffAutoConfigurationImportFilter
@@ -0,0 +1 @@
dev.caskeleton.adapter.outbound.messaging.autoconfigure.MessagingBridgeRootAutoConfiguration
@@ -16,7 +16,6 @@ import dev.caskeleton.application.notification.platform.security.SecretMaterialP
import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -83,9 +82,23 @@ public class NotificationPlatformAutoConfiguration {
};
}
/** Contact point protection, only once key material is available. */
/**
* Contact point protection.
*
* <p>{@code @ConditionalOnBean(SecretMaterialProvider.class)} used to guard this, to express
* "only once key material is available". It does not express that. This is a plain
* {@code @Configuration} imported by a root, so the condition is evaluated while configurations
* are still being parsed and answers according to what happens to be registered at that moment —
* and the secret provider is declared by a sibling configuration the same root imports. The
* answer was "absent", so the bean vanished and the application failed on an unsatisfied
* dependency several layers away, in the deployment that first assembled the platform for real.
*
* <p>Nothing is lost by dropping it. Both configurations are reached only through the same root
* under the same master switch, so the provider exists whenever this does — and a deployment
* missing key material already fails at the provider itself, at startup, naming the purpose whose
* key is missing. That is a better failure than a bean quietly not being created.
*/
@Bean
@ConditionalOnBean(SecretMaterialProvider.class)
@ConditionalOnMissingBean
public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
return new AesGcmContactPointProtector(secrets);
@@ -0,0 +1,211 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import jakarta.mail.Session;
import java.time.Clock;
import java.util.concurrent.Executor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
/**
* The SMTP provider family, assembled (NTF-INT-001).
*
* <p>{@code NotificationPlatformProviderConfig} injects {@code List<ProviderRuntimeAssembler>} and
* nothing in the composition contributed one, so the list was always empty. Assembly then produced
* a registry with no runtimes and {@code SERVING} accepted requests that could never find a route —
* which from outside is a platform silently dropping notifications. {@code
* SmtpProviderRuntimeAssembler} existed in main source throughout; the only thing that ever
* constructed it was its own unit test.
*
* <p>It lives in this leaf rather than in the composition root because the root must not carry
* {@code jakarta.mail} or {@code JavaMailSender} on its own compile classpath: the mail starter is
* an implementation detail of the family this package assembles, and a composition root that
* imports it to wire one provider has taken ownership of that provider's transport.
*
* <p>Gated on {@code spring.mail.host}, not on the notification switch alone. A deployment that
* turns the platform on without configuring a relay contributes no SMTP assembler, and assembly
* then refuses any profile that declares {@code type: SMTP} by naming the missing family — a better
* failure than a mail sender pointed at a default nobody chose.
*
* <p>The relay's address is Spring's, not a second copy. Host, port, credentials and transport
* security come from {@code spring.mail.*} through the auto-configured {@link JavaMailSender}. One
* relay described in two places is a defect this repository has already paid for: {@code
* app.jpa-platform.datasource.*} validated a pool that {@code spring.datasource.hikari.*} had
* built. {@link NotificationSmtpSettings} carries only what {@code spring.mail} has no word for —
* the envelope sender, the per-attempt timeouts and the dispatch concurrency.
*/
@Configuration(proxyBeanMethods = false)
// The master switch is not repeated here. This configuration is reachable only through
// NotificationPlatformRuntimeConfig, which the root imports under that switch, so "off" stays a
// structural fact rather than a condition each new configuration has to remember. What this one
// owns
// is the narrower question: is a relay configured at all.
@ConditionalOnProperty(name = "spring.mail.host")
@EnableConfigurationProperties(NotificationSmtpSettings.class)
public class NotificationSmtpProviderConfig {
/** The send itself, over the mail sender Spring configured. */
@Bean
@ConditionalOnMissingBean
public SmtpDispatch notificationSmtpDispatch(JavaMailSender sender) {
return new JavaMailSenderSmtpDispatch(sender);
}
/**
* The MIME factory, on the sender's own session.
*
* <p>The session is taken from {@link JavaMailSenderImpl} rather than created here, so the
* message is built with the same properties it will be sent with. A second session would let the
* factory and the transport disagree about encoding or TLS, and the disagreement would only show
* as a malformed message at a recipient.
*/
@Bean
@ConditionalOnMissingBean
public SmtpMimeMessageFactory notificationSmtpMimeMessageFactory(JavaMailSender sender) {
if (sender instanceof JavaMailSenderImpl impl) {
return new SmtpMimeMessageFactory(impl.getSession());
}
return new SmtpMimeMessageFactory(Session.getInstance(new java.util.Properties()));
}
/** SMTP reply code to retry decision. */
@Bean
@ConditionalOnMissingBean
public SmtpFailureClassifier notificationSmtpFailureClassifier() {
return new SmtpFailureClassifier();
}
/**
* Attachment resolution, refused by default.
*
* <p>Attachments come from the file server or object storage capability, and a deployment that
* enabled neither has no way to read one. The default refuses rather than returning null: a null
* resolution reaches the integrity guard, which then compares a digest against nothing, and the
* message goes out without the attachment it claimed to carry.
*/
@Bean
@ConditionalOnMissingBean
public AttachmentResolver notificationAttachmentResolver() {
return (reference, context) -> {
throw new IllegalStateException(
"an attachment was requested but no attachment capability is bound; enable the file "
+ "server or object storage capability, or submit a notification without attachments");
};
}
/** Digest check between what a template referenced and what was read. */
@Bean
@ConditionalOnMissingBean
public AttachmentIntegrityGuard notificationAttachmentIntegrityGuard(
AttachmentResolver resolver) {
return new AttachmentIntegrityGuard(resolver);
}
/**
* Where an SMTP send runs.
*
* <p>Bounded, and it aborts rather than queues without limit: an SMTP relay that has stopped
* answering turns an unbounded queue into retained heap and a dispatch worker that never notices.
* {@code CallerRunsPolicy} would instead block the dispatch loop on the slow relay, which stalls
* every other channel too.
*/
@Bean(name = "notificationSmtpExecutor", destroyMethod = "shutdown")
@ConditionalOnMissingBean(name = "notificationSmtpExecutor")
public Executor notificationSmtpExecutor(NotificationSmtpSettings settings) {
ThreadPoolExecutor executor =
new ThreadPoolExecutor(
1,
settings.maxConcurrency(),
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(settings.maxConcurrency() * 4),
runnable -> {
Thread thread = new Thread(runnable, "notification-smtp");
thread.setDaemon(true);
return thread;
},
new ThreadPoolExecutor.AbortPolicy());
executor.allowCoreThreadTimeOut(true);
return executor;
}
/**
* The assembler itself.
*
* @param dispatch the send
* @param mimeFactory the MIME builder
* @param classifier the outcome classifier
* @param protector redacts contact points on the way out
* @param notificationSmtpExecutor where a send runs
* @param attachmentGuard the attachment integrity check
* @param settings the parts of the relay description Spring's own mail properties do not carry
* @param sender the auto-configured sender, read for host and port
* @param clock the clock the rate limiter measures against
* @return the SMTP assembler, contributed into the assembly's list
*/
@Bean
@ConditionalOnMissingBean(name = "smtpProviderRuntimeAssembler")
public ProviderRuntimeAssembler smtpProviderRuntimeAssembler(
SmtpDispatch dispatch,
SmtpMimeMessageFactory mimeFactory,
SmtpFailureClassifier classifier,
ContactPointProtector protector,
Executor notificationSmtpExecutor,
AttachmentIntegrityGuard attachmentGuard,
NotificationSmtpSettings settings,
JavaMailSender sender,
Clock clock) {
return new SmtpProviderRuntimeAssembler(
dispatch,
mimeFactory,
classifier,
protector,
notificationSmtpExecutor,
attachmentGuard,
transport(settings, sender),
clock);
}
/**
* The relay's address, read from Spring's mail configuration.
*
* <p>{@code SmtpProviderProperties.TlsMode} has two members and neither is plaintext, so the type
* refuses an unencrypted relay by construction rather than by a validator somebody has to
* remember to run. That is why the mode is a setting rather than something derived from {@code
* spring.mail.properties.mail.smtp.starttls.enable}: a derived mode would silently become
* "whatever that flag happened to say", including off.
*/
private static SmtpProviderProperties transport(
NotificationSmtpSettings settings, JavaMailSender sender) {
String host = settings.host();
int port = settings.port();
if (sender instanceof JavaMailSenderImpl impl) {
host = impl.getHost() == null ? host : impl.getHost();
port = impl.getPort() > 0 ? impl.getPort() : port;
}
return new SmtpProviderProperties(
host,
port,
settings.tlsMode(),
settings.senderIdentity(),
settings.connectTimeout(),
settings.readTimeout(),
settings.writeTimeout(),
settings.maxConcurrency());
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import java.time.Duration;
import java.util.Objects;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* The parts of an SMTP relay description that {@code spring.mail.*} has no word for.
*
* <p>Host, port, username, password and the mail properties stay Spring's. Repeating them here
* would be one relay described twice, which is the shape of a defect this repository has already
* paid for — {@code app.jpa-platform.datasource.*} validated a pool that {@code
* spring.datasource.hikari.*} had built, so the validation passed while describing something that
* was not running.
*
* <p>{@code host} and {@code port} are present all the same, as a fallback only: {@link
* NotificationSmtpProviderConfig} reads them from the configured sender and falls back here when
* the sender is not the standard implementation. They are never the primary source.
*
* @param host fallback relay host, used only when the mail sender cannot be read
* @param port fallback relay port, same
* @param tlsMode transport security; the type has no plaintext member, deliberately
* @param senderIdentity the envelope sender every message is sent as
* @param connectTimeout how long a connection attempt may take
* @param readTimeout how long a reply may take
* @param writeTimeout how long a write may take
* @param maxConcurrency how many sends may run at once
*/
@ConfigurationProperties("ca-skeleton.notification.platform.smtp")
public record NotificationSmtpSettings(
@DefaultValue("localhost") String host,
@DefaultValue("587") int port,
@DefaultValue("STARTTLS_REQUIRED") SmtpProviderProperties.TlsMode tlsMode,
@DefaultValue("no-reply@example.invalid") String senderIdentity,
@DefaultValue("5s") Duration connectTimeout,
@DefaultValue("10s") Duration readTimeout,
@DefaultValue("10s") Duration writeTimeout,
@DefaultValue("4") int maxConcurrency) {
public NotificationSmtpSettings {
Objects.requireNonNull(tlsMode, "tlsMode");
Objects.requireNonNull(senderIdentity, "senderIdentity");
Objects.requireNonNull(connectTimeout, "connectTimeout");
Objects.requireNonNull(readTimeout, "readTimeout");
Objects.requireNonNull(writeTimeout, "writeTimeout");
// The sender identity has a default that cannot deliver on purpose. example.invalid is reserved
// by RFC 2606 and resolves nowhere, so a deployment that forgot to set one gets a bounce it can
// trace rather than mail that appears to come from a real address it does not own.
if (senderIdentity.isBlank() || senderIdentity.indexOf('@') <= 0) {
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.smtp.sender-identity must be an email address");
}
if (maxConcurrency < 1) {
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.smtp.max-concurrency must be at least 1");
}
}
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderAttemptLimiter;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntime;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpNotificationProviderAdapter;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executor;
/**
* The first production assembler: one configured SMTP profile becomes one working provider
* (NTF-INT-001).
*
* <p>{@code NotificationPlatformProviderConfig} assembles {@code List<ProviderRuntimeAssembler>}
* and production main source contained no implementation of that interface. So {@code SERVING}
* could not work in production whatever an operator configured: profiles bound, validation passed,
* and the runtime registry was built empty — requests reached durable acceptance and then found no
* eligible route, which reads from outside as the platform dropping notifications.
*
* <p>SMTP is the reference family because every piece above the wire already existed and was
* tested: the adapter, the MIME factory, the failure classifier. What was missing was the send
* itself ({@code SmtpDispatch} had no implementation) and this — the step that turns a profile into
* a runtime.
*
* <p><b>Where the relay's address comes from.</b> Host, port, credentials and TLS come from
* Spring's own {@code spring.mail.*} through the injected {@code JavaMailSender}, not from a second
* description on the provider profile. One relay described twice is the defect this repository has
* already paid for elsewhere — {@code app.jpa-platform.datasource.*} validated a pool that {@code
* spring.datasource.hikari.*} had built. The profile owns what is per-profile: its timeout, its
* concurrency and its rate.
*
* <p>Contributed as {@link AssembledProvider#dispatchOnly}, honestly: SMTP has no callback adapter,
* no provider-event projector and no status-query capability. Claiming any of them would produce a
* profile that fails on the first provider event rather than at startup.
*/
public final class SmtpProviderRuntimeAssembler implements ProviderRuntimeAssembler {
private final SmtpDispatch dispatch;
private final SmtpMimeMessageFactory mimeFactory;
private final SmtpFailureClassifier classifier;
private final ContactPointProtector protector;
private final Executor executor;
private final AttachmentIntegrityGuard attachmentGuard;
private final SmtpProviderProperties transport;
private final Clock clock;
/**
* Creates the assembler.
*
* @param dispatch the send, over the configured mail sender
* @param mimeFactory builds the MIME message
* @param classifier turns an SMTP outcome into a retry decision
* @param protector redacts contact points on the way out
* @param executor where a send runs
* @param attachmentGuard the attachment integrity check
* @param transport the relay's address and transport security, from {@code spring.mail.*}
* @param clock the clock the rate limiter measures against
*/
public SmtpProviderRuntimeAssembler(
SmtpDispatch dispatch,
SmtpMimeMessageFactory mimeFactory,
SmtpFailureClassifier classifier,
ContactPointProtector protector,
Executor executor,
AttachmentIntegrityGuard attachmentGuard,
SmtpProviderProperties transport,
Clock clock) {
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.executor = Objects.requireNonNull(executor, "executor");
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
this.transport = Objects.requireNonNull(transport, "transport");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public ProviderType type() {
return ProviderType.SMTP;
}
@Override
public AssembledProvider assemble(
String profileId, NotificationPlatformSettings.Provider profile) {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(profile, "profile");
SmtpProviderProperties properties =
new SmtpProviderProperties(
transport.host(),
transport.port(),
transport.tlsMode(),
transport.senderIdentity(),
transport.connectTimeout(),
profile.timeout(),
profile.timeout(),
profile.maxConcurrency());
SmtpNotificationProviderAdapter adapter =
new SmtpNotificationProviderAdapter(
dispatch, mimeFactory, classifier, protector, properties, executor, attachmentGuard);
ProviderRuntime runtime =
new ProviderRuntime(
snapshot(profileId, profile),
adapter,
new ProviderAttemptLimiter(profile.maxConcurrency(), profile.ratePerSecond(), clock));
return AssembledProvider.dispatchOnly(runtime, Channel.EMAIL);
}
private static ProviderProfileSnapshot snapshot(
String profileId, NotificationPlatformSettings.Provider profile) {
return new ProviderProfileSnapshot(
new ProviderProfileId(profileId),
new ProviderId(ProviderType.SMTP.name().toLowerCase(java.util.Locale.ROOT)),
Channel.EMAIL,
profile.environment(),
// No rotation has happened yet for a freshly assembled profile. The generation is what a
// rotation increments; starting anywhere but the first would make the first rotation look
// like it had already occurred.
1L,
capabilities(),
Map.of());
}
/**
* What SMTP can actually do, stated rather than assumed.
*
* <p>No batch, no provider-side idempotency, no status callback, no status query, no delivery
* receipt, no native scheduling, cancel or collapse. SMTP is a protocol for handing a message to
* a relay; everything past that is the relay's business and invisible to the sender. A capability
* declared here that the protocol does not have is a promise the dispatch loop will act on.
*/
private static ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false,
false,
false,
false,
false,
false,
false,
false,
1,
25L * 1024 * 1024,
// A relay that has not accepted the message within this is not going to; the platform's own
// queue-age bound is what decides how long a request keeps being retried.
java.time.Duration.ofHours(24));
}
}
@@ -96,10 +96,14 @@ public final class NotificationSchedulerWorker implements AutoCloseable {
// The lease is left to expire rather than being released optimistically: a worker
// that
// failed mid-dispatch cannot prove what the provider did.
// The cause chain by type, never by message. "reason=NotificationValidationException"
// alone identified nothing — a dozen checks raise it — and a library's exception text
// can carry a recipient address, so the types are named and the messages are not.
log.warn(
"notification dispatch failed worker={} reason={}",
"notification dispatch failed worker={} reason={} causes={}",
workerId,
failure.getClass().getSimpleName());
failure.getClass().getSimpleName(),
causeChain(failure));
} finally {
globalConcurrency.release();
}
@@ -108,6 +112,25 @@ public final class NotificationSchedulerWorker implements AutoCloseable {
return claimed.size();
}
/**
* The exception's cause chain, as type names only.
*
* @param failure the dispatch failure
* @return the chain, outermost first, bounded so a self-referential cause cannot loop
*/
private static String causeChain(Throwable failure) {
StringBuilder chain = new StringBuilder();
Throwable current = failure.getCause();
for (int depth = 0; current != null && depth < 8; depth++) {
if (chain.length() > 0) {
chain.append('<');
}
chain.append(current.getClass().getSimpleName());
current = current.getCause();
}
return chain.length() == 0 ? "none" : chain.toString();
}
/** Start the polling loop on a dedicated thread. */
public void start() {
if (!running.compareAndSet(false, true)) {
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import jakarta.mail.internet.MimeMessage;
import java.util.Objects;
import java.util.Optional;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
/**
* The production SMTP send, over Spring's {@link JavaMailSender} (NTF-INT-001).
*
* <p>{@link SmtpDispatch} was an interface with no implementation. The adapter above it, its MIME
* factory and its failure classifier were all complete and unit-tested against fakes, so the SMTP
* family looked finished from every angle except the one that matters: nothing could send. That is
* the shape of the whole NTF-INT-001 finding — configuration bound, validation passed, and the
* runtime registry constructed empty.
*
* <p>Failures are translated rather than propagated. {@link SmtpDispatchException} is what {@code
* SmtpFailureClassifier} reads to decide retryable from permanent, and a raw {@link MailException}
* reaching the dispatch loop would be classified as an unknown failure — which this platform treats
* as ambiguous, and ambiguous means the attempt is not retried, because a message that may already
* have been delivered must not be sent twice.
*/
public final class JavaMailSenderSmtpDispatch implements SmtpDispatch {
private final JavaMailSender sender;
/**
* Creates the dispatch.
*
* @param sender the configured mail sender, whose host, port and credentials come from Spring's
* own {@code spring.mail.*} — the namespace an operator already knows, rather than a second
* description of one relay
*/
public JavaMailSenderSmtpDispatch(JavaMailSender sender) {
this.sender = Objects.requireNonNull(sender, "sender");
}
@Override
public void send(MimeMessage message) {
Objects.requireNonNull(message, "message");
try {
sender.send(message);
} catch (MailException failure) {
// dataCommitted = true, deliberately and conservatively. Spring collapses every JavaMail
// outcome into MailException subtypes that do not say whether the body reached the server
// before the connection broke, and the platform reads this flag to decide whether a retry
// could duplicate a delivered message. Claiming "not committed" without evidence is the
// assumption that sends a notification twice; claiming "committed" costs a delivery that has
// to be reconciled, which is the failure this platform is built to survive.
throw new SmtpDispatchException("SMTP_TRANSPORT_FAILURE", Optional.empty(), true, failure);
}
}
}
@@ -45,11 +45,16 @@ public final class SmtpMimeMessageFactory {
Objects.requireNonNull(attachments, "attachments");
if (!(submission.content().content() instanceof EmailContent email)) {
throw rejection();
// A static cause, not the value: which check fired is diagnostic, what it saw may be content.
throw rejection(
new IllegalStateException(
"rendered content for an SMTP submission is "
+ submission.content().content().getClass().getSimpleName()
+ ", not EmailContent"));
}
requireHeaderSafe(recipientAddress);
requireHeaderSafe(fromAddress);
requireHeaderSafe(email.subject());
requireHeaderSafe(recipientAddress, "recipient");
requireHeaderSafe(fromAddress, "sender");
requireHeaderSafe(email.subject(), "subject");
try {
MimeMessage message = new MimeMessage(session);
@@ -71,25 +76,38 @@ public final class SmtpMimeMessageFactory {
attachment.displayName(), () -> attachment.content(), attachment.contentType());
}
for (var header : email.options().approvedHeaders().entrySet()) {
requireHeaderSafe(header.getKey());
requireHeaderSafe(header.getValue());
requireHeaderSafe(header.getKey(), "approved header name");
requireHeaderSafe(header.getValue(), "approved header value");
message.setHeader(header.getKey(), header.getValue());
}
return message;
} catch (MessagingException failure) {
throw rejection();
throw rejection(failure);
}
}
private static void requireHeaderSafe(String value) {
private static void requireHeaderSafe(String value, String field) {
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
throw rejection();
// The field name, never the value: a header-injection attempt is exactly the payload that
// must
// not be echoed into a log.
throw rejection(new IllegalStateException(field + " contains a header separator"));
}
}
private static NotificationValidationException rejection() {
return new NotificationValidationException(
/**
* The rejection, carrying what caused it.
*
* <p>The descriptor is deliberately the same for every construction failure — it is what gets
* stored on the delivery row, and a per-check code there would be a cardinality problem. The
* cause is what tells an operator which check fired, and dropping it made an SMTP dispatch
* failure indistinguishable from any other: a lane saw "reason=NotificationValidationException"
* and nothing else.
*/
private static NotificationValidationException rejection(Throwable cause) {
NotificationFailureDescriptor descriptor =
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD);
return new NotificationValidationException(descriptor, cause);
}
}
@@ -0,0 +1,149 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* AES-GCM at rest, with the key id in the envelope (NTF-INT-007).
*
* <h2>The envelope</h2>
*
* <pre>
* byte version always 1
* byte keyIdLength 1..255, UTF-8 bytes
* byte[] keyId
* byte[12] nonce
* byte[] ciphertext + GCM tag
* </pre>
*
* <p><b>The key id is the whole point of having a format at all.</b> This repository's callback
* protection stores nonce and ciphertext and nothing else, so the day the active key changes, every
* row written under the previous one becomes unreadable and nothing in the row can say which key it
* needed. That is not a rotation story with a gap in it; it is the absence of one. Reading the id
* back out and asking the secret store for that specific key is what makes rotation a change of
* default rather than a data migration.
*
* <p>The version byte is here for the same reason and costs one byte: a format that cannot say
* which format it is can only ever be changed by rewriting every row first.
*
* <p>Authentication is not an add-on. GCM verifies the tag on decrypt, so a modified ciphertext
* fails rather than producing plausible-looking variables — which for a notification payload would
* mean rendering attacker-chosen content into a message a recipient trusts.
*/
public final class AesGcmNotificationPayloadProtection implements NotificationPayloadProtection {
/** The only version this class writes, and the only one it reads. */
static final byte VERSION = 1;
private static final int NONCE_BYTES = 12;
private static final int TAG_BITS = 128;
private static final int MAX_KEY_ID_BYTES = 255;
private final SecretMaterialProvider secrets;
private final SecureRandom random;
/**
* Creates the protection.
*
* @param secrets the key store, which owns the active key and every retired one
* @param random the nonce source
*/
public AesGcmNotificationPayloadProtection(SecretMaterialProvider secrets, SecureRandom random) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.random = Objects.requireNonNull(random, "random");
}
@Override
public byte[] protect(byte[] plaintext) {
Objects.requireNonNull(plaintext, "plaintext");
SecretKeyMaterial key = secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION);
byte[] keyId = key.keyId().getBytes(StandardCharsets.UTF_8);
if (keyId.length == 0 || keyId.length > MAX_KEY_ID_BYTES) {
throw new IllegalStateException(
"a payload encryption key id must be 1..255 UTF-8 bytes to fit the envelope; this one is "
+ keyId.length);
}
byte[] nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(key.material(), "AES"),
new GCMParameterSpec(TAG_BITS, nonce));
// The header is authenticated, not merely prefixed: without this, the key id and version are
// attacker-editable, and an envelope could be redirected at a key of the attacker's choosing.
byte[] header = header(keyId);
cipher.updateAAD(header);
byte[] ciphertext = cipher.doFinal(plaintext);
return ByteBuffer.allocate(header.length + nonce.length + ciphertext.length)
.put(header)
.put(nonce)
.put(ciphertext)
.array();
} catch (GeneralSecurityException failure) {
// The message is deliberately shapeless: a failure here is about keys and providers, and
// anything derived from the plaintext would put caller content into a log line.
throw new IllegalStateException("notification payload encryption failed", failure);
}
}
@Override
public byte[] reveal(byte[] envelope) {
Objects.requireNonNull(envelope, "envelope");
if (envelope.length < 2) {
throw new NotificationPayloadUnreadableException(
"the stored payload is not an envelope", "unknown", null);
}
if (envelope[0] != VERSION) {
throw new NotificationPayloadUnreadableException(
"unsupported payload envelope version " + envelope[0], "unknown", null);
}
int keyIdLength = Byte.toUnsignedInt(envelope[1]);
int nonceStart = 2 + keyIdLength;
if (keyIdLength == 0 || envelope.length < nonceStart + NONCE_BYTES) {
throw new NotificationPayloadUnreadableException(
"the stored payload envelope is truncated", "unknown", null);
}
String keyId = new String(envelope, 2, keyIdLength, StandardCharsets.UTF_8);
try {
SecretKeyMaterial key = secrets.keyById(keyId);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(key.material(), "AES"),
new GCMParameterSpec(TAG_BITS, envelope, nonceStart, NONCE_BYTES));
cipher.updateAAD(envelope, 0, nonceStart);
int cipherStart = nonceStart + NONCE_BYTES;
return cipher.doFinal(envelope, cipherStart, envelope.length - cipherStart);
} catch (GeneralSecurityException | RuntimeException failure) {
// Every reason collapses into one type on purpose. An unknown key, a wrong key and a modified
// ciphertext are the same event to a caller — the payload cannot be read — and telling them
// apart in the message tells an attacker which of the three they achieved.
throw new NotificationPayloadUnreadableException(
"the stored notification payload could not be decrypted", keyId, failure);
}
}
private static byte[] header(byte[] keyId) {
return ByteBuffer.allocate(2 + keyId.length)
.put(VERSION)
.put((byte) keyId.length)
.put(keyId)
.array();
}
}
@@ -28,7 +28,7 @@ public final class JacksonNotificationVariablesCodec implements NotificationVari
try {
return NotificationJsonMapper.mapper().writeValueAsString(new TreeMap<>(variables));
} catch (JacksonException failure) {
throw rejection();
throw rejection(failure);
}
}
@@ -39,13 +39,14 @@ public final class JacksonNotificationVariablesCodec implements NotificationVari
return NotificationJsonMapper.mapper()
.readValue(payload, new TypeReference<TreeMap<String, Object>>() {});
} catch (JacksonException failure) {
throw rejection();
throw rejection(failure);
}
}
private static NotificationValidationException rejection() {
private static NotificationValidationException rejection(Throwable cause) {
return new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
cause);
}
}
@@ -0,0 +1,133 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpProviderProperties;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import jakarta.mail.Session;
import java.time.Clock;
import java.time.Duration;
import java.util.Properties;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* One configured profile becomes one working provider (NTF-INT-001).
*
* <p>Before this assembler existed, {@code NotificationPlatformProviderConfig} collected {@code
* List<ProviderRuntimeAssembler>} and production main source implemented the interface nowhere. A
* fully configured SMTP profile therefore produced no runtime, no route and no error: requests
* reached durable acceptance and then found nothing eligible to send them, which reads from outside
* as the platform silently dropping notifications.
*/
class SmtpProviderRuntimeAssemblerTest {
@Test
@DisplayName("the assembler serves the SMTP family on the email channel")
void theAssemblerServesSmtpOnEmail() {
assertThat(assembler().type()).isEqualTo(ProviderType.SMTP);
assertThat(ProviderType.SMTP.channel()).isEqualTo(Channel.EMAIL);
}
@Test
@DisplayName("a configured profile assembles into a runtime bound to its own profile id")
void aConfiguredProfileAssemblesIntoARuntime() {
AssembledProvider assembled = assembler().assemble("primary-email", profile());
assertThat(assembled.channel()).isEqualTo(Channel.EMAIL);
assertThat(assembled.runtime().profile().profileId().value()).isEqualTo("primary-email");
assertThat(assembled.runtime().profile().providerId().value()).isEqualTo("smtp");
assertThat(assembled.runtime().profile().environment()).isEqualTo("local");
}
@Test
@DisplayName("SMTP contributes dispatch and claims nothing it cannot do")
void smtpClaimsNothingItCannotDo() {
AssembledProvider assembled = assembler().assemble("primary-email", profile());
assertThat(assembled.callback())
.as("SMTP has no callback adapter; claiming one fails on the first provider event instead")
.isEmpty();
assertThat(assembled.projector()).isEmpty();
assertThat(assembled.reconciliation())
.as("handing a message to a relay is the end of what the sender can observe")
.isEmpty();
var capabilities = assembled.runtime().profile().capabilities();
assertThat(capabilities.statusCallback()).isFalse();
assertThat(capabilities.statusQuery()).isFalse();
assertThat(capabilities.providerIdempotency())
.as("a capability declared here is a promise the dispatch loop acts on")
.isFalse();
}
@Test
@DisplayName("the per-profile bounds come from the profile, not from the shared transport")
void thePerProfileBoundsComeFromTheProfile() {
AssembledProvider assembled = assembler().assemble("primary-email", profile());
assertThat(assembled.runtime().profile().credentialGeneration())
.as("a freshly assembled profile has had no rotation; starting elsewhere fakes one")
.isEqualTo(1L);
}
private static SmtpProviderRuntimeAssembler assembler() {
Session session = Session.getInstance(new Properties());
return new SmtpProviderRuntimeAssembler(
message -> {
throw new UnsupportedOperationException("assembly only; nothing is sent here");
},
new SmtpMimeMessageFactory(session),
new SmtpFailureClassifier(),
protector(),
Runnable::run,
new AttachmentIntegrityGuard((reference, context) -> null),
new SmtpProviderProperties(
"localhost",
1025,
// The type cannot express plaintext: STARTTLS_REQUIRED and IMPLICIT_TLS are the
// only members, which is the transport refusing an unencrypted relay by
// construction rather than by a validator somebody has to remember to run.
SmtpProviderProperties.TlsMode.STARTTLS_REQUIRED,
"no-reply@example.test",
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(5),
4),
Clock.systemUTC());
}
private static NotificationPlatformSettings.Provider profile() {
return new NotificationPlatformSettings.Provider(
"SMTP", true, true, "local", "smtp-local", "", "", "", Duration.ofSeconds(10), 4, 8);
}
/** Not a lambda: the protector has three operations, and only one of them is exercised here. */
private static dev.caskeleton.application.notification.platform.security.ContactPointProtector
protector() {
return new dev.caskeleton.application.notification.platform.security.ContactPointProtector() {
@Override
public dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
protect(
dev.caskeleton.application.notification.platform.contact.ContactPointValue value) {
throw new UnsupportedOperationException("assembly only");
}
@Override
public dev.caskeleton.application.notification.platform.contact.ContactPointValue reveal(
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint value,
dev.caskeleton.application.notification.platform.security.AccessContext context) {
throw new UnsupportedOperationException("assembly only");
}
@Override
public String fingerprint(
dev.caskeleton.application.notification.platform.contact.ContactPointValue value) {
throw new UnsupportedOperationException("assembly only");
}
};
}
}
@@ -0,0 +1,195 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The at-rest envelope, and the rotation it exists to make possible (NTF-INT-007).
*
* <p>The accept path stored template variables verbatim — caller content that can be a reset code,
* an order total or an address. What decides whether an encryption layer is real is not that it
* encrypts; it is whether the day the key changes is a change of default or a data migration.
*/
class AesGcmNotificationPayloadProtectionTest {
private static final byte[] PAYLOAD =
"{\"code\":\"481516\",\"total\":\"1250.00\"}".getBytes(StandardCharsets.UTF_8);
@Test
@DisplayName("a payload survives the round trip unchanged")
void aPayloadSurvivesTheRoundTrip() {
Keys keys = new Keys("payload-2026-08");
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] envelope = protection.protect(PAYLOAD);
assertThat(protection.reveal(envelope)).isEqualTo(PAYLOAD);
}
@Test
@DisplayName("the stored bytes contain no part of the plaintext")
void theStoredBytesRevealNothing() {
Keys keys = new Keys("payload-2026-08");
byte[] envelope =
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
assertThat(new String(envelope, StandardCharsets.UTF_8))
.as("a column an operator can read is a column an incident can read")
.doesNotContain("481516")
.doesNotContain("1250.00");
}
@Test
@DisplayName("two encryptions of one payload differ")
void twoEncryptionsDiffer() {
var protection = new AesGcmNotificationPayloadProtection(new Keys("k1"), new SecureRandom());
assertThat(protection.protect(PAYLOAD))
.as("a deterministic ciphertext tells an observer which two requests carried one payload")
.isNotEqualTo(protection.protect(PAYLOAD));
}
@Test
@DisplayName("a row written under a retired key is still readable after rotation")
void aRetiredKeyStillReads() {
Keys keys = new Keys("payload-2026-08");
var beforeRotation = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] oldRow = beforeRotation.protect(PAYLOAD);
keys.rotateTo("payload-2026-09");
assertThat(new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).reveal(oldRow))
.as(
"this is the whole reason the envelope carries a key id. Without one, rotation is a "
+ "one-way door: every row written under the previous key becomes unreadable and "
+ "nothing in the row can say which key it needed")
.isEqualTo(PAYLOAD);
}
@Test
@DisplayName("a new row uses the new key, so rotation actually rotates")
void aNewRowUsesTheNewKey() {
Keys keys = new Keys("payload-2026-08");
keys.rotateTo("payload-2026-09");
byte[] envelope =
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
assertThat(keyIdOf(envelope)).isEqualTo("payload-2026-09");
}
@Test
@DisplayName("a modified ciphertext is refused rather than decrypted into something plausible")
void aModifiedCiphertextIsRefused() {
Keys keys = new Keys("k1");
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] envelope = protection.protect(PAYLOAD);
envelope[envelope.length - 1] ^= 0x01;
assertThatThrownBy(() -> protection.reveal(envelope))
.as("rendering attacker-chosen variables into a message a recipient trusts is the risk")
.isInstanceOf(NotificationPayloadUnreadableException.class);
}
@Test
@DisplayName("an edited key id is refused, because the header is authenticated")
void anEditedKeyIdIsRefused() {
Keys keys = new Keys("k1");
keys.add("k2");
var protection = new AesGcmNotificationPayloadProtection(keys, new SecureRandom());
byte[] envelope = protection.protect(PAYLOAD);
envelope[2] = 'k';
envelope[3] = '2';
assertThatThrownBy(() -> protection.reveal(envelope))
.as(
"the header is passed as AAD, so redirecting an envelope at another key fails the tag "
+ "rather than being attempted")
.isInstanceOf(NotificationPayloadUnreadableException.class);
}
@Test
@DisplayName("an unknown key names itself in the failure")
void anUnknownKeyNamesItself() {
Keys keys = new Keys("payload-2026-08");
byte[] envelope =
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()).protect(PAYLOAD);
Keys emptied = new Keys("payload-2026-09");
assertThatThrownBy(
() ->
new AesGcmNotificationPayloadProtection(emptied, new SecureRandom())
.reveal(envelope))
.isInstanceOf(NotificationPayloadUnreadableException.class)
.as("the operator's next question is always which key is missing")
.hasMessageContaining("payload-2026-08");
}
@Test
@DisplayName("a truncated or foreign envelope is refused, not misread")
void aTruncatedEnvelopeIsRefused() {
var protection = new AesGcmNotificationPayloadProtection(new Keys("k1"), new SecureRandom());
assertThatCode(() -> protection.reveal(new byte[] {1}))
.isInstanceOf(NotificationPayloadUnreadableException.class);
assertThatCode(
() -> protection.reveal("plaintext row from before".getBytes(StandardCharsets.UTF_8)))
.as("a pre-migration plaintext row must fail loudly rather than decode into nonsense")
.isInstanceOf(NotificationPayloadUnreadableException.class);
}
private static String keyIdOf(byte[] envelope) {
return new String(envelope, 2, Byte.toUnsignedInt(envelope[1]), StandardCharsets.UTF_8);
}
/** A key store with history, which is what rotation needs and what the envelope addresses. */
private static final class Keys implements SecretMaterialProvider {
private final Map<String, SecretKeyMaterial> byId = new LinkedHashMap<>();
private String activeId;
Keys(String activeId) {
add(activeId);
this.activeId = activeId;
}
void add(String keyId) {
byte[] material = new byte[32];
Arrays.fill(material, (byte) keyId.hashCode());
byId.put(keyId, new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, material));
}
void rotateTo(String keyId) {
add(keyId);
activeId = keyId;
}
@Override
public SecretKeyMaterial activeKey(SecretPurpose purpose) {
return byId.get(activeId);
}
@Override
public SecretKeyMaterial keyById(String keyId) {
SecretKeyMaterial key = byId.get(keyId);
if (key == null) {
throw new IllegalStateException("no key " + keyId);
}
return key;
}
}
}
@@ -22,7 +22,7 @@ adapters implement application/domain ports directly and must not depend on this
The platform in `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md` is
implemented here. The design models it as 18 Stable library modules; this repository's fail-closed
19-leaf registry outranks that layout, so those modules are **packages** in this leaf and
registry outranks that layout, so those modules are **packages** in this leaf and
`docs/jpa/repository-adaptation.md` records the mapping. Read it before moving a type between
packages.
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.persistence.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/**
* Registers the JPA adapter's scanned components, which nothing registered (JPA-INT-006).
*
* <p>The composition root's {@code @ComponentScan} excludes {@code
* dev.caskeleton.adapter.outbound.persistence.**} by regex, and that exclusion is correct: it is
* what makes an optional capability optional, so a deployment with JPA off assembles no persistence
* beans rather than assembling them and hoping each one remembered to carry the switch.
*
* <p>What was missing is the other half. Eight classes in this leaf are written as scanned
* components — {@code SpringTransactionPort}, {@code PersistenceExceptionTranslator}, {@code
* StandardSqlStateErrorMapping}, {@code DomainContextAuditContextPort}, the idempotency store and
* its reaper, and the outbox store and its reaper — and once the broad scan stopped reaching them,
* nothing else did. They are annotated {@code @Component} and {@code @Repository} and were beans in
* no running application: {@code TransactionPort} in particular had no implementation at all, so
* every use case that opens a transaction had no port to open it with.
*
* <p>It surfaced as an unsatisfied dependency the first time a capability that needs a transaction
* was actually assembled — the notification orchestrator, in the local-notification-ingest lane —
* rather than as anything a unit test could see, because each of these classes is constructed
* directly by its own tests.
*
* <p>So the scan is restored, narrowed to the packages it should always have covered and reachable
* only through {@code PersistenceJpaRootAutoConfiguration}, which carries the JPA master switch.
* Off is still structural.
*
* <p>Two packages are deliberately absent:
*
* <ul>
* <li>{@code ..persistence.fileserver} — gated on its own capability switch, scanned by {@link
* dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig};
* <li>{@code ..persistence.notification} — assembled explicitly, bean by bean, by {@code
* NotificationJpaPersistenceFacade}.
* </ul>
*
* <p>Components under these packages keep their own {@code @ConditionalOnProperty} guards; being
* scanned makes them candidates, not unconditional beans.
*
* <p>{@code ..persistence.lock} is in the list for the same reason and with the same history.
* {@code DistributedLockPersistenceConfig} owns both lock providers — the in-process registry and
* the JDBC one — and was registered by nothing but a test calling {@code ctx.register(...)}. So a
* single-instance deployment had no {@code DistributedLockPort} at all, and a multi-instance one
* could not start: the composition root's own {@code DistributedLockConfig} asks for a bean
* qualified {@code jdbcDistributedLock} that only that configuration declares.
*
* <p>Each package's {@code @ConfigurationProperties} type is enabled by a configuration inside that
* same package — {@code JpaTransactionConfig} for {@code JpaTransactionSettings}, {@code
* DistributedLockPersistenceConfig} for {@code LockSettings} — rather than from here. Enabling them
* centrally would give {@code config} an edge to {@code lock} and {@code transaction} that the
* module map does not grant it, and the map is right: this class knows which packages to scan, not
* what is inside them. They need enabling at all because {@code @ConfigurationPropertiesScan}
* excludes this tree as deliberately as {@code @ComponentScan} does.
*/
@Configuration(proxyBeanMethods = false)
@ComponentScan(
basePackages = {
"dev.caskeleton.adapter.outbound.persistence.audit",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
"dev.caskeleton.adapter.outbound.persistence.outbox",
"dev.caskeleton.adapter.outbound.persistence.transaction"
})
public class JpaAdapterComponentsConfig {}
@@ -11,13 +11,25 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
* simple name avoids {@code JpaConfig} to dodge a bean-name collision with the sample module. See
* README "config".
*
* <p>The package list is enumerated rather than given as the persistence root, and the omission is
* deliberate: {@code ...persistence.notification} is an <em>opt-in</em> capability whose schema
* stream is not in the default Flyway location. Scanning the whole root put its entities into the
* persistence unit unconditionally, so a deployment that never enabled notification still had
* {@code ddl-auto=validate} looking for {@code notification_request} — and failed to boot over a
* capability it had switched off. {@code NotificationJpaPersistenceConfig} owns that scan and only
* when the capability is on.
* <p>The package list is enumerated rather than given as the persistence root, and two omissions
* are deliberate. {@code ...persistence.notification} and {@code ...persistence.fileserver} are
* <em>opt-in</em> capabilities whose schema streams are not in the default Flyway location, so
* their tables do not exist in a deployment that never asked for them. Scanning the whole root put
* those entities into the persistence unit unconditionally, and {@code ddl-auto=validate} then
* looked for {@code notification_request} and {@code fs_cleanup_item} in deployments that had
* switched both capabilities off — failing the boot over capabilities they had declined. {@code
* NotificationJpaPersistenceConfig} and {@code FileserverJpaPersistenceConfig} own those scans,
* each behind the same switch its adapter beans already carried.
*
* <p>The fileserver case was the more expensive of the two: it blocked every JPA-on Compose lane,
* and it was invisible under H2, whose {@code create-drop} builds whatever the entities describe.
* It took a real PostgreSQL with a real migration history to see.
*
* <p>Neither gated configuration is imported from here, and neither is found by a component scan:
* {@code dev.caskeleton.adapter.outbound.persistence..*} is excluded from the composition root's
* scan by design, and {@code config} is allowed to depend on {@code api} alone. The composition
* root registers them, which is where the decision belongs — it is the only place that knows both
* which capabilities are on and which JPA vendor is composed.
*
* <p>A new always-installed sub-package must be added here; leaving it out is a silent omission
* rather than a compile error, which is what {@code PersistenceEntityScanCoverageTest} checks.
@@ -32,7 +44,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"dev.caskeleton.adapter.outbound.persistence.envers",
"dev.caskeleton.adapter.outbound.persistence.experimental",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.fileserver",
"dev.caskeleton.adapter.outbound.persistence.hibernate",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
@@ -54,7 +65,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"dev.caskeleton.adapter.outbound.persistence.envers",
"dev.caskeleton.adapter.outbound.persistence.experimental",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.fileserver",
"dev.caskeleton.adapter.outbound.persistence.hibernate",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
@@ -69,7 +79,13 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
})
public class PersistenceJpaConfig {
/** The one sub-package deliberately excluded above, named so a test can assert the exclusion. */
/**
* An opt-in sub-package deliberately excluded above, named so a test can assert the exclusion.
*/
public static final String OPT_IN_NOTIFICATION_PACKAGE =
"dev.caskeleton.adapter.outbound.persistence.notification";
/** The other one. Its tables live only in {@code db/migration/jpa/fileserver}. */
public static final String OPT_IN_FILESERVER_PACKAGE =
"dev.caskeleton.adapter.outbound.persistence.fileserver";
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.fileserver;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Scans the fileserver capability's entities and repositories, and only when it is enabled.
*
* <p>The second capability to need this, and it was found the same way as the first: by running.
* Six entities here map six tables — {@code fs_file}, {@code fs_upload_session}, {@code
* fs_verification_result}, {@code fs_quota_reservation}, {@code fs_recovery_item} and {@code
* fs_cleanup_item} — and all six live in {@code db/migration/jpa/fileserver}, a stream applied only
* when the capability is on. The primary Flyway location is {@code db/migration/postgresql}, which
* creates none of them.
*
* <p>So an unconditional scan put those entities into the persistence unit of every deployment, and
* {@code ddl-auto=validate} against real PostgreSQL failed on {@code fs_cleanup_item} — a table the
* deployment had correctly never created, for a capability it had switched off. Every JPA-on
* Compose lane was blocked on it. The adapter beans in this package already carried this exact
* condition; the entity metadata did not, so "disabled" meant two different things one annotation
* apart.
*
* <p>The condition is the same master switch those beans use, so disabled means one thing
* everywhere: no entity metadata, no repository beans, no schema expectation.
*
* <p>{@code @ComponentScan} is here for the reason {@link
* dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig} exists: the nine
* {@code @Repository} adapters in this package carry the condition quoted above but were reached by
* no scan at all once the composition root stopped scanning the persistence tree, so the condition
* had nothing to decide about. {@code @EnableJpaRepositories} does not cover them — it registers
* Spring Data interfaces, and these are classes that consume those interfaces.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
@EntityScan(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
@EnableJpaRepositories(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
@ComponentScan(basePackages = "dev.caskeleton.adapter.outbound.persistence.fileserver")
public class FileserverJpaPersistenceConfig {}
@@ -3,6 +3,7 @@ package dev.caskeleton.adapter.outbound.persistence.lock;
import dev.caskeleton.application.lock.DistributedLockPort;
import javax.sql.DataSource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@@ -17,6 +18,10 @@ import org.springframework.integration.support.locks.DefaultLockRegistry;
* "lock" for the provider-selection and wiring rationale.
*/
@Configuration(proxyBeanMethods = false)
// LockSettings is bound here rather than by the composition root's @ConfigurationPropertiesScan,
// which excludes this tree so a JPA-off deployment binds no persistence settings. Both providers
// below are built from its lease TTL, so without this the configuration cannot assemble either.
@EnableConfigurationProperties(LockSettings.class)
public class DistributedLockPersistenceConfig {
/** In-process adapter; active when {@code multi-instance-enabled} is {@code false} or absent. */
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.outbound.persistence.notification.configuration;
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig;
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation;
import dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository;
import dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository;
@@ -56,6 +57,7 @@ import java.util.Locale;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcOperations;
/**
@@ -84,6 +86,14 @@ import org.springframework.jdbc.core.JdbcOperations;
prefix = "ca-skeleton.notification.platform",
name = "enabled",
havingValue = "true")
// The entity scan travels with the stores it serves. NotificationJpaPersistenceConfig carries the
// same condition and was imported by nothing at all — the composition root's component scan
// excludes
// this package by design and no configuration named it — so the capability had entity metadata
// nowhere, whether it was switched on or off. Importing it here means a composition that reaches
// the
// facade cannot get the stores without the mappings they need.
@Import(NotificationJpaPersistenceConfig.class)
public class NotificationJpaPersistenceFacade {
/**
@@ -132,11 +142,21 @@ public class NotificationJpaPersistenceFacade {
.JdbcNotificationServingState(jdbc, clock);
}
/** Entity/record mapping. */
/**
* Entity/record mapping, including the at-rest envelope (NTF-INT-007).
*
* <p>The protection is a required constructor argument rather than an optional one, so a
* composition cannot assemble this leaf's notification stores while leaving the payload in
* plaintext. That was the whole risk: the store existed, wiring it was one import away, and
* nothing about the store's shape said the row it wrote held caller content unprotected.
*/
@Bean
public NotificationRecordMapper notificationRecordMapper(
NotificationRoutingPlanCodecPort routingPlans, NotificationVariablesCodecPort variables) {
return new NotificationRecordMapper(routingPlans, variables);
NotificationRoutingPlanCodecPort routingPlans,
NotificationVariablesCodecPort variables,
dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection
payloadProtection) {
return new NotificationRecordMapper(routingPlans, variables, payloadProtection);
}
/** Request and recipient persistence. */
@@ -163,7 +183,19 @@ public class NotificationJpaPersistenceFacade {
return new JpaRecipientLeaseStore(recipients, clock);
}
/** Attempt persistence, which also serves the resolver and projection ports. */
/**
* Attempt persistence, which is also the resolver and the projection store.
*
* <p>One bean, not four. {@link JpaDeliveryAttemptStore} implements {@link
* DeliveryAttemptStorePort}, {@link DeliveryAttemptResolverPort} and {@link
* DeliveryProjectionStorePort}, so injection by any of those types already finds it — and three
* further {@code @Bean} methods returning this same instance under three more names is what made
* the container refuse to start: asking for the concrete type matched several definitions of one
* object, and {@code deliveryAttemptResolverPort} could not be built at all.
*
* <p>It failed only once this facade was actually assembled. Nothing imported it before, so the
* duplication sat in a class the runtime never read.
*/
@Bean
public JpaDeliveryAttemptStore deliveryAttemptStore(
DeliveryAttemptJpaRepository attempts,
@@ -173,24 +205,6 @@ public class NotificationJpaPersistenceFacade {
return new JpaDeliveryAttemptStore(attempts, recipients, hasher, clock);
}
/** Attempt store as its application port. */
@Bean
public DeliveryAttemptStorePort deliveryAttemptStorePort(JpaDeliveryAttemptStore store) {
return store;
}
/** Attempt resolution for incoming provider events. */
@Bean
public DeliveryAttemptResolverPort deliveryAttemptResolverPort(JpaDeliveryAttemptStore store) {
return store;
}
/** Projection persistence. */
@Bean
public DeliveryProjectionStorePort deliveryProjectionStorePort(JpaDeliveryAttemptStore store) {
return store;
}
/** Append-only provider event ledger. */
@Bean
public ProviderEventLedger providerEventLedger(
@@ -61,7 +61,10 @@ public final class JpaNotificationRequestStore implements NotificationRequestSto
? null
: request.template().locale().toLanguageTag(),
request.strategyType(),
request.variablesPayload(),
// Through the mapper, not raw. This native insert names every column itself and so
// bypasses toEntity, where payload protection lives; passing the plaintext here stored
// recipient-facing variables in clear text and produced a row nothing could read back.
mapper.protectPayload(request.variablesPayload()),
request.scheduleAt().orElse(null),
request.notBefore().orElse(null),
request.expiresAt().orElse(null),
@@ -14,7 +14,11 @@ import dev.caskeleton.application.notification.platform.dispatch.NotificationReq
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -31,11 +35,27 @@ public final class NotificationRecordMapper {
private final NotificationRoutingPlanCodecPort routingPlans;
private final NotificationVariablesCodecPort variables;
private final NotificationPayloadProtection payloadProtection;
/**
* Creates the mapper.
*
* @param routingPlans the frozen route-plan codec
* @param variables the variables codec
* @param payloadProtection the at-rest envelope (NTF-INT-007). Applied here, at the storage
* boundary, rather than in the accept path: "at rest" means in the row, and the application
* necessarily holds the plaintext in memory because it has to render it. What this removes is
* the plaintext sitting in {@code notification_request.variables_payload} for as long as the
* request is retained — caller content that can be a reset code, an order total or an
* address.
*/
public NotificationRecordMapper(
NotificationRoutingPlanCodecPort routingPlans, NotificationVariablesCodecPort variables) {
NotificationRoutingPlanCodecPort routingPlans,
NotificationVariablesCodecPort variables,
NotificationPayloadProtection payloadProtection) {
this.routingPlans = Objects.requireNonNull(routingPlans, "routingPlans");
this.variables = Objects.requireNonNull(variables, "variables");
this.payloadProtection = Objects.requireNonNull(payloadProtection, "payloadProtection");
}
/** Map a stored request. */
@@ -53,7 +73,7 @@ public final class NotificationRecordMapper {
? Locale.ROOT
: Locale.forLanguageTag(entity.templateLocale())),
entity.strategyType(),
entity.variablesPayload(),
revealPayload(entity.variablesPayload()),
Optional.ofNullable(entity.scheduleAt()),
Optional.ofNullable(entity.notBefore()),
Optional.ofNullable(entity.expiresAt()),
@@ -98,6 +118,51 @@ public final class NotificationRecordMapper {
entity.updatedAt());
}
/**
* Wraps the payload for storage: an AES-GCM envelope, base64 for the text column.
*
* <p>Base64 rather than a column type change, deliberately. Moving {@code variables_payload} to
* {@code bytea} would touch the entity, the mapper, the native upsert and every stored row, and
* would buy a third of the bytes; keeping the column and changing what is in it makes the
* migration a rewrite of values rather than of a schema.
*
* <p>Public because {@link #toEntity} is not the only write path. {@code
* JpaNotificationRequestStore.insert} claims the idempotency key with a native insert that names
* every column itself, so it never reaches the mapper — and it wrote the plaintext straight into
* the column. Every accepted request was stored unencrypted, and the first read-back refused it
* as "not an envelope", which is the reveal side working exactly as intended and the only reason
* this was visible at all. A store that writes rows this mapper is expected to read must protect
* them the way this mapper protects them.
*/
public String protectPayload(String plaintext) {
if (plaintext == null) {
return null;
}
return Base64.getEncoder()
.encodeToString(payloadProtection.protect(plaintext.getBytes(StandardCharsets.UTF_8)));
}
/**
* Unwraps a stored payload.
*
* <p>A row that cannot be decrypted throws rather than returning null or empty. A caller handed
* an empty payload renders every variable as nothing and sends "Hello , your code is " to a real
* person — the failure delivered instead of reported.
*/
private String revealPayload(String stored) {
if (stored == null) {
return null;
}
byte[] envelope;
try {
envelope = Base64.getDecoder().decode(stored);
} catch (IllegalArgumentException malformedEnvelope) {
throw new NotificationPayloadUnreadableException(
"the stored notification payload is not a base64 envelope", "unknown", malformedEnvelope);
}
return new String(payloadProtection.reveal(envelope), StandardCharsets.UTF_8);
}
/** Build a new request entity. */
public NotificationRequestEntity toEntity(NotificationRequestRecord record) {
return new NotificationRequestEntity(
@@ -110,7 +175,7 @@ public final class NotificationRecordMapper {
record.template().version(),
record.template().locale().toLanguageTag(),
record.strategyType(),
record.variablesPayload(),
protectPayload(record.variablesPayload()),
record.scheduleAt().orElse(null),
record.notBefore().orElse(null),
record.expiresAt().orElse(null),
@@ -15,20 +15,26 @@ import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcOperations;
/**
* PostgreSQL vendor persistence configuration: imports the core JPA config and registers the vendor
* {@code @Bean}s. See the module README.
* PostgreSQL vendor persistence configuration: registers the vendor {@code @Bean}s. See the module
* README.
*
* <p>The vendor configuration is the entry point into the capability, and it imports the core JPA
* config rather than the reverse. Inverting that to give the composition root a single import
* produced a package cycle — {@code config} would import {@code postgresql}, which needs {@code
* PersistenceVendorSettings} back from {@code config} — so the composition root names both vendor
* configurations instead, and the persistence export surface says so explicitly.
*
* <p>{@code matchIfMissing = true} keeps PostgreSQL the default: this configuration was
* unconditional before {@link PersistenceVendorSettings} existed, and a deployment that never sets
* the selector must keep the vendor it already runs.
*/
@Configuration(proxyBeanMethods = false)
@Import(PersistenceJpaConfig.class)
@ConditionalOnProperty(
prefix = PersistenceVendorSettings.PREFIX,
name = "vendor",
havingValue = "postgresql",
matchIfMissing = true)
@Import(PersistenceJpaConfig.class)
public class PostgreSqlPersistenceConfig {
@Bean
@@ -52,8 +58,32 @@ public class PostgreSqlPersistenceConfig {
return new PostgreSqlIdempotencyClaimRepository(entityManager);
}
/**
* The vendor's migration location, as a default rather than as an override.
*
* <p>This unconditionally called {@code locations(...)}, which replaces whatever Spring bound
* from {@code spring.flyway.locations}. An operator could therefore set {@code
* SPRING_FLYWAY_LOCATIONS} to add the capability streams, watch Flyway report a successful
* migration, and get only the vendor stream — the property was read, bound, and then discarded by
* a customizer that runs after it. The {@code local-notification-ingest} lane set seven locations
* and applied one.
*
* <p>It is the same shape as {@code application-local.yml}'s literal pins, which outranked every
* environment a caller supplied, and the same fix: contribute the value when nobody has chosen
* one, and stay out of the way when somebody has. A deployment that names its own locations is
* responsible for including this one, which is exactly the responsibility it took by naming them.
*
* @param environment the resolved environment, consulted for an operator-supplied value
* @return the customizer
*/
@Bean
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
return configuration -> configuration.locations("classpath:db/migration/postgresql");
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer(
org.springframework.core.env.Environment environment) {
return configuration -> {
String chosen = environment.getProperty("spring.flyway.locations", "").trim();
if (chosen.isEmpty()) {
configuration.locations("classpath:db/migration/postgresql");
}
};
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.persistence.transaction;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Binds {@link JpaTransactionSettings}, which nothing bound.
*
* <p>{@link SpringTransactionPort} takes it as a constructor argument, and the composition root's
* {@code @ConfigurationPropertiesScan} deliberately excludes this tree so a deployment with JPA off
* binds no persistence settings. That exclusion left the type unbound in deployments with JPA
* <em>on</em> as well, so the transaction port could not be constructed at all — it surfaced as an
* unsatisfied dependency the first time a capability that opens a transaction was assembled.
*
* <p>It lives in this package, next to the settings and the component that needs them, because the
* module map grants {@code config} an edge to {@code api} only; a central enablement would need
* edges into every package whose settings it named.
*
* <p>Reached through {@code JpaAdapterComponentsConfig}'s scan, so it is registered exactly when
* the JPA master switch is on.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(JpaTransactionSettings.class)
public class JpaTransactionConfig {}
@@ -0,0 +1,27 @@
-- five-adapter-runtime-remediation Wave 2 — the capability stream's half of the same correction.
--
-- V1 here creates idempotency_record with `CREATE TABLE IF NOT EXISTS` and char(64), the same
-- declaration db/migration/postgresql/V1 uses. The two streams keep separate history tables and
-- their relative order is not fixed, so whichever creates the table has to be corrected by whichever
-- runs next. Both carry the same guarded conversion; the reasoning is recorded once, in
-- db/migration/postgresql/V10__idempotency_request_hash_varchar.sql.
--
-- Editing V1 in place would have been smaller and wrong: an applied migration's checksum is a
-- promise to every deployment that already ran it.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = 'idempotency_record'
AND a.attname = 'request_hash'
AND NOT a.attisdropped
AND format_type(a.atttypid, a.atttypmod) = 'character(64)'
) THEN
ALTER TABLE idempotency_record
ALTER COLUMN request_hash TYPE varchar(64);
END IF;
END
$$;
@@ -0,0 +1,48 @@
-- five-adapter-runtime-remediation Wave 2 / NTF-INT-007 — the at-rest envelope's row guard.
--
-- notification_request.variables_payload now holds a base64 AES-GCM envelope instead of the accepted
-- request's template variables in plaintext. The variables are the message's own content — a reset
-- code, an order total, a delivery address — so the column held caller-supplied sensitive data for as
-- long as the request was retained.
--
-- This is a guard rather than a backfill, and the reason is a fact worth stating: no deployment of
-- this repository can have written such a row. NotificationJpaPersistenceFacade, which assembles
-- JpaNotificationRequestStore, was imported by nothing, so the write path existed in code and was
-- reachable from no composition. A backfill here would be re-encrypting rows that cannot exist, and
-- it would need the key material, which a migration has no business holding.
--
-- A fork that wired the store itself is the case this exists for. It fails the migration rather than
-- letting the application meet the rows at runtime, where the mapper throws
-- NotificationPayloadUnreadableException per request and the failure looks like a decryption bug
-- instead of an un-migrated table.
--
-- Silently reinterpreting a plaintext row is the option this deliberately does not take. Accepting
-- both shapes would mean the protection can be bypassed by writing plaintext, which is a control that
-- announces itself and then declines to hold.
DO $$
DECLARE
unprotected bigint;
BEGIN
IF to_regclass('public.notification_request') IS NULL THEN
RETURN;
END IF;
-- A base64 envelope contains only the base64 alphabet; a stored JSON payload contains at least
-- one of { " : , which none of them is. That is enough to separate the two without decoding.
SELECT count(*)
INTO unprotected
FROM notification_request
WHERE variables_payload IS NOT NULL
AND variables_payload !~ '^[A-Za-z0-9+/]+=*$';
IF unprotected > 0 THEN
RAISE EXCEPTION
'notification_request holds % row(s) whose variables_payload is not an at-rest envelope. '
'These predate NTF-INT-007 and contain caller content in plaintext. Re-encrypt them with '
'the active PAYLOAD_ENCRYPTION key before applying this migration; this step refuses to '
'reinterpret them, because accepting both shapes would let the protection be bypassed by '
'writing plaintext.', unprotected;
END IF;
END
$$;
@@ -0,0 +1,39 @@
-- five-adapter-runtime-remediation Wave 2 — align request_hash with the mapping that validates it.
--
-- V1 declared request_hash as char(64) while IdempotencyRecordEntity maps it as length = 64, which
-- Hibernate reads as varchar(64). Startup with ddl-auto=validate against real PostgreSQL therefore
-- failed:
--
-- Schema-validation: wrong column type encountered in column [request_hash] in table
-- [idempotency_record]; found [bpchar (Types#CHAR)], but expecting [varchar(64) (Types#VARCHAR)]
--
-- It was invisible for as long as local development ran on H2, whose create-drop builds the schema
-- from the entities and so can never disagree with them. Every other string column in this table is
-- varchar; char(n) was the outlier, and PostgreSQL gives it no storage or speed advantage while
-- blank-padding every value.
--
-- varchar is the direction rather than changing the entity, because a 64-character hex digest never
-- uses the padding and the rest of the codebase — the entity, the H2 composition, the JPQL — already
-- assumes varchar. The bpchar-to-varchar cast strips trailing blanks, so a padded value converts
-- losslessly.
--
-- Guarded because this stream and db/migration/jpa/idempotency both create the table and their
-- relative order is not fixed: whichever ran first, this converts only a column that is still
-- character(64), and a table rewrite is not paid twice.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = 'idempotency_record'
AND a.attname = 'request_hash'
AND NOT a.attisdropped
AND format_type(a.atttypid, a.atttypmod) = 'character(64)'
) THEN
ALTER TABLE idempotency_record
ALTER COLUMN request_hash TYPE varchar(64);
END IF;
END
$$;
@@ -0,0 +1,101 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
import jakarta.persistence.EntityManagerFactory;
import java.util.Map;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
/**
* Hibernate's verdict on the always-installed mapping, against the always-applied migrations.
*
* <p>The notification and fileserver capabilities each have this check; the default persistence
* unit — the one every JPA deployment builds whether it enables a capability or not — did not. So
* the only thing that ever ran {@code validate} over it against real PostgreSQL was a Compose lane,
* four minutes at a time, and what it found was {@code request_hash} declared {@code char(64)} by
* the migration and mapped {@code varchar(64)} by the entity. Every JPA-on deployment failed to
* start on it.
*
* <p>Nothing caught it earlier because local development runs on H2, whose {@code create-drop}
* builds the schema from the entities and therefore cannot disagree with them. A vendor that
* generates the schema can never report a mismatch with it.
*
* <p>The scanned packages are read from {@link PersistenceJpaConfig} rather than listed here, so a
* package added to the shipped scan is covered by this test the moment it is added — which is the
* only arrangement that keeps the check honest as the unit grows.
*/
@Tag("jpa-migration")
class PostgreSqlDefaultPersistenceUnitIntegrationTest {
@Test
@DisplayName("Hibernate validates the always-installed mapping against the migrated schema")
void hibernateValidatesTheDefaultUnitAgainstTheMigratedSchema() throws Exception {
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
migrate(database);
assertThatCode(() -> entityManagerFactory(database.dataSource()).close())
.as(
"this is the check every JPA-on boot performs; a column type the mapping disagrees "
+ "with fails it, and until this test existed only a Compose lane would say so")
.doesNotThrowAnyException();
}
}
@Test
@DisplayName(
"request_hash is varchar, which is what the entity maps and what the rest of the "
+ "table uses")
void theIdempotencyHashColumnIsVarchar() throws Exception {
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
migrate(database);
String type =
new JdbcTemplate(database.dataSource())
.queryForObject(
"select format_type(a.atttypid, a.atttypmod)"
+ " from pg_attribute a"
+ " join pg_class c on c.oid = a.attrelid"
+ " where c.relname = 'idempotency_record'"
+ " and a.attname = 'request_hash'"
+ " and not a.attisdropped",
String.class);
assertThat(type)
.as("char(n) blank-pads every value and was the only such column in this table")
.isEqualTo("character varying(64)");
}
}
private static EntityManagerFactory entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factoryBean =
new LocalContainerEntityManagerFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setPackagesToScan(
PersistenceJpaConfig.class.getAnnotation(EntityScan.class).basePackages());
factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
factoryBean.setJpaPropertyMap(
Map.of(
"hibernate.hbm2ddl.auto", "validate",
"hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"));
factoryBean.afterPropertiesSet();
return factoryBean.getObject();
}
private static void migrate(PostgreSqlReadinessSupport database) {
Flyway.configure()
.dataSource(database.dataSource())
.locations("classpath:db/migration/postgresql")
.load()
.migrate();
}
}
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.config;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig;
import dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -20,24 +21,45 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
* What goes into the persistence unit, and what is allowed to stay out of it.
*
* <p>The scan named the persistence root, so every sub-package was in the unit whether the
* deployment wanted it or not. Notification is opt-in — its schema stream is not in the default
* Flyway location — so a deployment with the feature switched off still had {@code
* ddl-auto=validate} demanding {@code notification_request}, and failed to boot over a capability
* it had declined.
* deployment wanted it or not. Notification and fileserver are opt-in — their schema streams are
* not in the default Flyway location — so a deployment with the feature switched off still had
* {@code ddl-auto=validate} demanding {@code notification_request} or {@code fs_cleanup_item}, and
* failed to boot over a capability it had declined.
*
* <p>Both were found by running, not reading, and the second only after the first was fixed:
* notification failed a Testcontainers boot, fileserver blocked every JPA-on Compose lane. Neither
* is visible under H2, whose {@code create-drop} builds whatever the entities describe.
*
* <p>An enumerated list fixes that and introduces a different risk: a new always-installed
* sub-package is a silent omission rather than a compile error. This test is the other half.
* sub-package is a silent omission rather than a compile error. This test is the other half — and
* it is parameterized over the opt-in capabilities so a third one cannot be added with half the
* treatment.
*/
class PersistenceEntityScanCoverageTest {
/** Each opt-in capability: its package, its own configuration, and the switch that gates it. */
private record OptInCapability(String packageName, Class<?> configuration, String switchPrefix) {}
private static final List<OptInCapability> OPT_IN =
List.of(
new OptInCapability(
PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE,
NotificationJpaPersistenceConfig.class,
"ca-skeleton.notification.platform"),
new OptInCapability(
PersistenceJpaConfig.OPT_IN_FILESERVER_PACKAGE,
FileserverJpaPersistenceConfig.class,
"app.fileserver-platform"));
/** Sub-packages that hold no JPA entity or repository and therefore need no scan. */
private static final Set<String> NOT_SCANNED =
Set.of(
// Vendor-selection configuration only.
"config",
"h2",
// The opt-in capability, scanned by NotificationJpaPersistenceConfig instead.
"notification");
// The opt-in capabilities, each scanned by its own gated configuration instead.
"notification",
"fileserver");
@Test
@DisplayName("every persistence sub-package is either scanned or explicitly exempt")
@@ -61,40 +83,56 @@ class PersistenceEntityScanCoverageTest {
}
@Test
@DisplayName("the opt-in notification package is not in the always-installed scan")
void notificationIsNotScannedUnconditionally() {
@DisplayName("no opt-in package is in the always-installed scan")
void noOptInPackageIsScannedUnconditionally() {
List<String> entityPackages =
List.of(PersistenceJpaConfig.class.getAnnotation(EntityScan.class).basePackages());
List<String> repositoryPackages =
List.of(
PersistenceJpaConfig.class.getAnnotation(EnableJpaRepositories.class).basePackages());
for (OptInCapability capability : OPT_IN) {
assertThat(entityPackages)
.as(
"scanning %s here puts its tables into ddl-auto=validate for every deployment",
capability.packageName())
.doesNotContain(capability.packageName());
assertThat(repositoryPackages).doesNotContain(capability.packageName());
}
assertThat(entityPackages)
.as("scanning it here puts its tables into ddl-auto=validate for every deployment")
.doesNotContain(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
assertThat(repositoryPackages).doesNotContain(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
assertThat(entityPackages)
.as("and naming the root would scan it by inclusion")
.as("and naming the root would scan every one of them by inclusion")
.doesNotContain("dev.caskeleton.adapter.outbound.persistence");
}
@Test
@DisplayName("the notification scan is behind the same switch as its beans")
void notificationIsScannedOnlyWhenEnabled() {
var condition =
NotificationJpaPersistenceConfig.class.getAnnotation(
org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class);
@DisplayName("each opt-in scan is behind the same switch as its own beans")
void everyOptInScanIsGatedByItsCapabilitySwitch() {
for (OptInCapability capability : OPT_IN) {
var condition =
capability
.configuration()
.getAnnotation(
org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class);
assertThat(condition).isNotNull();
assertThat(condition.prefix()).isEqualTo("ca-skeleton.notification.platform");
assertThat(condition.name()).containsExactly("enabled");
assertThat(condition.havingValue()).isEqualTo("true");
assertThat(
List.of(
NotificationJpaPersistenceConfig.class
.getAnnotation(EntityScan.class)
.basePackages()))
.containsExactly(PersistenceJpaConfig.OPT_IN_NOTIFICATION_PACKAGE);
assertThat(condition)
.as(
"%s must carry the capability condition, or the scan is unconditional again",
capability.configuration().getSimpleName())
.isNotNull();
assertThat(condition.prefix()).isEqualTo(capability.switchPrefix());
assertThat(condition.name()).containsExactly("enabled");
assertThat(condition.havingValue()).isEqualTo("true");
assertThat(List.of(capability.configuration().getAnnotation(EntityScan.class).basePackages()))
.containsExactly(capability.packageName());
assertThat(
List.of(
capability
.configuration()
.getAnnotation(EnableJpaRepositories.class)
.basePackages()))
.as("entities without repositories is half a scan, and fails at the first query")
.containsExactly(capability.packageName());
}
}
private static Set<String> scannedLeafNames(Class<?> configuration) {
@@ -292,12 +292,17 @@ Closure<String> renderMongoApiSurface = {
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
// The approval flag is read at configuration time and carried in, not fetched from `project`
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
// incompatible with the configuration cache — which this build will need before it can adopt one.
boolean mongoApiSurfaceUpdateApproved = project.hasProperty('approveMongoApiSurfaceChange')
tasks.register('verifyMongoApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (project.hasProperty('approveMongoApiSurfaceChange')) {
if (mongoApiSurfaceUpdateApproved) {
throw new GradleException(
'verifyMongoApiSurface is read-only; use updateMongoApiSurface to record an ' +
'approved change.')
@@ -22,6 +22,6 @@ import org.springframework.context.annotation.Configuration;
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPersistenceProperties.class)
@EnableConfigurationProperties(MongoPersistenceSettings.class)
@ImportAutoConfiguration({MongoAutoConfiguration.class, DataMongoAutoConfiguration.class})
public class MongoPersistenceConfig {}
@@ -7,12 +7,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* ca-skeleton.persistence-mongo.*}.
*
* <p>The Mongo <b>connection URI</b> is intentionally NOT modelled here it is read from Spring's
* own standard {@code spring.data.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}),
* which keeps credentials, host, and database wiring in the one place operators already expect.
* This class owns only the module's opt-in switch.
* own standard {@code spring.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}), which
* keeps credentials, host, and database wiring in the one place operators already expect. This
* class owns only the module's opt-in switch.
*
* <p>{@code spring.mongodb.*} is the canonical namespace in Spring Boot 4; {@code
* spring.data.mongodb.*} is deprecated at error level in its metadata. This Javadoc named the
* deprecated one, which is the worst place for that drift to sit: an operator reads the class that
* owns the switch, sets the property it points at, and gets a deprecation they did not choose. The
* Compose lanes have always supplied {@code SPRING_MONGODB_URI}, so only the documentation was
* behind. {@code MongoNamespaceContractTest} keeps it from drifting back.
*/
@ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo")
public class MongoPersistenceProperties {
public class MongoPersistenceSettings {
/**
* Whether to activate the MongoDB scaffolding. Defaults to {@code false} so the driver never
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.mongo;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Import;
/**
* The one place that decides whether this application talks to MongoDB.
*
* <p>Three things behaved like the master before this: the auto-configuration import filter, a
* component-scanned persistence configuration, and the platform auto-configuration — each reading
* the same property, and each able to assemble something the others believed was off. The filter
* keeps its job of holding Boot's own Mongo auto-configurations out of the candidate set, because
* that has to happen before any project condition is evaluated; what it no longer is, is an
* authority.
*
* <p>It sits in the leaf root, not in {@code autoconfigure}, because it names the opt-in
* configuration and the enablement settings that live here — and the root package already names
* {@code autoconfigure}. Declaring the edge both ways would make the module's package graph cyclic,
* which its boundary test refuses; the leaf root is where this file belongs by that graph's own
* description, "the opt-in filter and the Spring configuration entry points".
*/
@AutoConfiguration
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPersistenceSettings.class)
@Import({
MongoPersistenceConfig.class,
MongoPlatformAutoConfiguration.class,
MongoDriverObservabilityAutoConfiguration.class
})
public class MongoRootAutoConfiguration {}
@@ -24,7 +24,7 @@ import org.springframework.context.annotation.Configuration;
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoAdvancedProperties.class)
@EnableConfigurationProperties(MongoAdvancedSettings.class)
public class MongoAdvancedConfiguration {
/**
@@ -37,7 +37,7 @@ public class MongoAdvancedConfiguration {
@Bean
@ConditionalOnMissingBean
public MongoAdvancedCapabilityGuard mongoAdvancedCapabilityGuard(
MongoAdvancedProperties properties) {
MongoAdvancedSettings properties) {
return new MongoAdvancedCapabilityGuard(properties.toFlags());
}
}
@@ -22,19 +22,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* startup instead of being ignored which matters more than usual here, because the failure mode
* of a misspelt Advanced flag is a capability that stays off while its operator believes it is on.
*/
@ConfigurationProperties(MongoAdvancedProperties.PREFIX)
public record MongoAdvancedProperties(Map<MongoCapability, CapabilitySwitch> advanced) {
@ConfigurationProperties(MongoAdvancedSettings.PREFIX)
public record MongoAdvancedSettings(Map<MongoCapability, CapabilitySwitch> advanced) {
/** The module prefix; the {@code advanced} component completes the documented property path. */
public static final String PREFIX = "ca-skeleton.persistence-mongo";
public MongoAdvancedProperties {
public MongoAdvancedSettings {
advanced = advanced == null || advanced.isEmpty() ? Map.of() : Map.copyOf(advanced);
}
/** Nothing enabled — the default for a deployment that configures no Advanced capability. */
public static MongoAdvancedProperties none() {
return new MongoAdvancedProperties(Map.of());
public static MongoAdvancedSettings none() {
return new MongoAdvancedSettings(Map.of());
}
/** The flag set this configuration describes. */
@@ -2,7 +2,6 @@ package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
import dev.caskeleton.adapter.outbound.mongo.observation.MongoDriverObservabilityConfiguration;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -25,7 +24,7 @@ import org.springframework.context.annotation.Bean;
* different things on purpose, and neither is derived from the other, so a command appears once in
* each rather than twice in one.
*/
@AutoConfiguration
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
@@ -38,9 +37,9 @@ public class MongoDriverObservabilityAutoConfiguration {
@ConditionalOnMissingBean(name = "mongoDriverObservabilityCustomizer")
public MongoClientSettingsBuilderCustomizer mongoDriverObservabilityCustomizer(
MeterRegistry registry,
org.springframework.beans.factory.ObjectProvider<MongoPlatformProperties> properties) {
org.springframework.beans.factory.ObjectProvider<MongoPlatformSettings> properties) {
String profile =
properties.getIfAvailable(MongoPlatformProperties::empty).profiles().keySet().stream()
properties.getIfAvailable(MongoPlatformSettings::empty).profiles().keySet().stream()
.findFirst()
.orElse("default");
MongoDriverObservabilityConfiguration observability =
@@ -17,7 +17,6 @@ import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -39,13 +38,13 @@ import org.springframework.data.mongodb.core.MongoTemplate;
* transitively. The admin gateway is absent for the same reason, and deliberately: it is
* constructed by a migration or deployment job with its own credential.
*/
@AutoConfiguration
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@ConditionalOnClass(MongoTemplate.class)
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-mongo",
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPlatformProperties.class)
@EnableConfigurationProperties(MongoPlatformSettings.class)
@Import(MongoMappingConfiguration.class)
public class MongoPlatformAutoConfiguration {
@@ -148,7 +147,7 @@ public class MongoPlatformAutoConfiguration {
@Bean
@ConditionalOnBean(MongoTopologyProbe.class)
public InitializingBean mongoPlatformStartupCheck(
MongoPlatformProperties properties,
MongoPlatformSettings properties,
MongoTopologyProbe probe,
ObjectProvider<dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile>
runtimeSecurity,
@@ -174,15 +173,54 @@ public class MongoPlatformAutoConfiguration {
probe,
security,
admin,
// Capability flags come from the same beans a deployment supplies for them; the
// properties record carries only profiles.
true,
true,
// From settings, not literals. These were `true, true`, which told the validator that
// transactions and change streams were both wanted whatever the deployment had
// configured — and then validated the topology against that invented answer.
properties.transactions(),
properties.changeStreams(),
versions)
.validate();
};
}
/**
* Refuses a platform that is on with no way to look at the server it talks to (MNG-INT-003).
*
* <p>{@code mongoPlatformStartupCheck} above is conditioned on a {@link MongoTopologyProbe},
* which is right — the check is about the live server, and only a composition root knows how to
* reach it. But a condition is also an exit: a deployment that enables the platform and supplies
* no probe got no validation at all, silently, and not supplying a bean is exactly what an
* operator who has not finished wiring will do.
*
* <p>So the absence is a failure of its own. Deliberately <b>not</b> conditioned on the probe: a
* requirement that only applies when the thing it requires is present is not a requirement.
*
* <p>Scoped to a platform that is actually configured, which is what {@code profiles} being
* non-empty means — the settings record already treats "module opted in, no platform profile yet"
* as a state that must start. This repository ships no probe: it is built from the live
* data-plane client by the composition root that owns the connection, which is a fork's decision.
* Requiring one from every deployment that merely switches the module on would refuse the
* module's own opt-in contract, and the {@code local-mongo} lane with it.
*/
@Bean
public InitializingBean mongoTopologyProbeRequirement(
MongoPlatformSettings properties, ObjectProvider<MongoTopologyProbe> probe) {
return () -> {
if (properties.profiles().isEmpty()) {
return;
}
if (probe.getIfAvailable() == null) {
throw new IllegalStateException(
"the Mongo platform has configured profiles but no MongoTopologyProbe bean, so the "
+ "startup validator has nothing to ask about the server: supply a probe built from "
+ "the live data-plane client, or remove the platform profiles. Starting without one "
+ "means the topology, the Stable API level and the credential's real capabilities "
+ "are checked by nothing — silently, because the check was conditioned on the very "
+ "bean whose absence it should report.");
}
};
}
/**
* The credential generation registry.
*
@@ -211,7 +249,7 @@ public class MongoPlatformAutoConfiguration {
@ConditionalOnMissingBean
@ConditionalOnBean(MongoTopologyProbe.class)
public MongoPlatformHealthIndicator mongoPlatformHealthIndicator(
MongoTopologyProbe probe, MongoPlatformProperties properties) {
MongoTopologyProbe probe, MongoPlatformSettings properties) {
return new MongoPlatformHealthIndicator(probe, properties, 0);
}
}
@@ -20,7 +20,7 @@ public final class MongoPlatformHealthIndicator {
private final MongoTopologyProbe probe;
private final MongoPlatformProperties properties;
private final MongoPlatformSettings properties;
private final int availableSecondaries;
@@ -30,7 +30,7 @@ public final class MongoPlatformHealthIndicator {
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
public MongoPlatformHealthIndicator(
MongoTopologyProbe probe, MongoPlatformProperties properties, int availableSecondaries) {
MongoTopologyProbe probe, MongoPlatformSettings properties, int availableSecondaries) {
this(probe, properties, availableSecondaries, DEFAULT_REQUIRED_SECONDARIES);
}
@@ -44,7 +44,7 @@ public final class MongoPlatformHealthIndicator {
*/
public MongoPlatformHealthIndicator(
MongoTopologyProbe probe,
MongoPlatformProperties properties,
MongoPlatformSettings properties,
int availableSecondaries,
int requiredSecondaries) {
this.probe = Objects.requireNonNull(probe, "probe");
@@ -16,20 +16,35 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* <p>Every profile is validated at binding time. A misconfigured profile that is only reached at
* runtime fails during the first request that touches it, which is both later and harder to
* attribute than a refused startup.
*
* <p>{@code transactions} is a subordinate switch, off until a deployment asks for it; when on, the
* startup validator verifies the data-plane credential's real replica-set capability rather than
* assuming it. Both it and {@code changeStreams} used to be literal {@code true}s passed into
* {@link MongoStartupValidator}, which told the validator that two capabilities were wanted
* whatever the deployment had configured.
*/
@ConfigurationProperties("ca-skeleton.persistence-mongo.platform")
public record MongoPlatformProperties(Map<String, MongoProfileProperties> profiles) {
public record MongoPlatformSettings(
Map<String, MongoProfileProperties> profiles, boolean transactions, boolean changeStreams) {
public MongoPlatformProperties {
public MongoPlatformSettings {
// Absent rather than empty is the normal case: a deployment that has opted the module in but
// configured no platform profile yet must still start, so binding treats "no profiles" as an
// empty map instead of a binding failure.
profiles = profiles == null ? Map.of() : Map.copyOf(profiles);
// Experimental, and therefore not a switch (MNG-INT-003). The driver-side source watch,
// resumeAfter/startAfter, cursor lifetime, reconnection is not shipped; what exists is policy
// and value objects that do not add up to a running consumer. Accepting the flag and ignoring
// it
// would leave an operator believing it took effect, so the value is refused rather than stored:
// zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks
// honoured.
changeStreams = false;
}
/** An empty configuration, for a deployment that has not opted the platform in. */
public static MongoPlatformProperties empty() {
return new MongoPlatformProperties(Map.of());
public static MongoPlatformSettings empty() {
return new MongoPlatformSettings(Map.of(), false, false);
}
/**
@@ -21,7 +21,7 @@ import java.util.Optional;
*/
public final class MongoStartupValidator {
private final MongoPlatformProperties properties;
private final MongoPlatformSettings properties;
private final MongoTopologyProbe probe;
@@ -36,7 +36,7 @@ public final class MongoStartupValidator {
private final MongoSchemaVersionRange schemaVersionRange;
public MongoStartupValidator(
MongoPlatformProperties properties,
MongoPlatformSettings properties,
MongoTopologyProbe probe,
MongoSecurityProfile runtimeSecurity,
MongoCredentialReference adminCredential,
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.outbound.mongo.client;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.ServerApi;
import com.mongodb.ServerApiVersion;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings;
import dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialResolver;
import dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import org.bson.UuidRepresentation;
/**
* The one place the typed profile becomes the settings the driver is built from (MNG-INT-002).
*
* <p>The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout
* policy all existed and were all unit-tested. None of them reached a {@link MongoClientSettings}:
* the values were checked as intermediate objects and whatever the driver ended up configured with
* was decided elsewhere, by defaults nobody had chosen. A policy that nothing applies reads exactly
* like a policy that is applied — the tests pass, the record is populated, and the client connects
* with a three-second timeout it inherited from the driver rather than the two the profile states.
*
* <p>Two cardinality rules this enforces by construction:
*
* <ul>
* <li><b>One credential resolution, for the active profile only.</b> A profile present in the map
* but not selected never has its secret read — resolving it reaches a secret store for a
* credential nobody asked for, and the audit trail then records an access that happened for
* no reason anybody can reconstruct.
* <li><b>The connection string does not escape.</b> The resolver hands it to a function; what
* comes back is a settings object carrying only what the driver needs. The URI is a local for
* the duration of one call and never a field, a bean property or a {@code toString}.
* </ul>
*/
public final class MongoClientSettingsFactory {
private final MongoPlatformSettings settings;
private final String activeProfile;
private final MongoCredentialResolver credentials;
/**
* Creates the factory.
*
* @param settings the platform settings holding every configured profile
* @param activeProfile the profile this deployment selected
* @param credentials the resolver that turns a {@code secret://} reference into a URI
*/
public MongoClientSettingsFactory(
MongoPlatformSettings settings, String activeProfile, MongoCredentialResolver credentials) {
this.settings = Objects.requireNonNull(settings, "settings");
this.activeProfile = Objects.requireNonNull(activeProfile, "activeProfile");
this.credentials = Objects.requireNonNull(credentials, "credentials");
}
/**
* Builds the settings for the active profile.
*
* @return the driver settings
* @throws dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException when
* the selected profile is not configured
*/
public MongoClientSettings create() {
MongoProfileProperties profile = settings.require(activeProfile);
MongoCredentialReference reference =
new MongoCredentialReference(profile.uriSecret(), MongoPrincipalRole.APP_WRITE);
return credentials.withConnectionString(reference, uri -> build(profile, uri));
}
private static MongoClientSettings build(MongoProfileProperties profile, String uri) {
MongoClientSettings.Builder builder =
MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(uri))
// Pinned from the manifest rather than left to the driver, whose own default has
// changed
// across major versions — a value that moves under a stored document is a migration
// nobody wrote.
.uuidRepresentation(uuidRepresentationOf(profile))
.applyToSocketSettings(
socket ->
socket
.connectTimeout(profile.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)
.readTimeout(profile.socketReadTimeout().toMillis(), TimeUnit.MILLISECONDS))
.applyToClusterSettings(
cluster ->
cluster.serverSelectionTimeout(
profile.serverSelectionTimeout().toMillis(), TimeUnit.MILLISECONDS))
.applyToConnectionPoolSettings(
pool ->
pool.minSize(profile.poolMinSize())
.maxSize(profile.poolMaxSize())
.maxWaitTime(profile.poolMaxWaitTime().toMillis(), TimeUnit.MILLISECONDS));
if (profile.tlsRequired()) {
// Stated by the profile, applied here. A profile that declares TLS and connects without it is
// the failure this leaf's production validation exists to prevent, and it cannot prevent it
// from a record the driver never reads.
builder.applyToSslSettings(ssl -> ssl.enabled(true));
}
if (profile.stableApiStrict()) {
builder.serverApi(
ServerApi.builder()
.version(ServerApiVersion.V1)
.strict(true)
.deprecationErrors(true)
.build());
}
return builder.build();
}
private static UuidRepresentation uuidRepresentationOf(MongoProfileProperties profile) {
return switch (profile.uuidRepresentation()) {
case STANDARD -> UuidRepresentation.STANDARD;
// Readable for migration and never written, which is the manifest's rule rather than the
// driver's: the driver would happily write subtype 3 if told to.
case JAVA_LEGACY_READ_ONLY -> UuidRepresentation.JAVA_LEGACY;
};
}
}

Some files were not shown because too many files have changed in this diff Show More