Compare commits
375 Commits
538591989c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21a9d6046 | ||
|
|
0481cefaf4 | ||
|
|
040ae60be5 | ||
|
|
d8f7452ab7 | ||
|
|
c4fe16048c | ||
|
|
cba420fbeb | ||
|
|
67ec409383 | ||
|
|
3384510f3c | ||
|
|
18e6f32f90 | ||
|
|
4df6fc9e03 | ||
|
|
2aa5100530 | ||
|
|
c360d9ad5f | ||
|
|
e7952dd9de | ||
|
|
687598952f | ||
|
|
c22580e124 | ||
|
|
a5c20830a7 | ||
|
|
9231a1fc60 | ||
| f325416833 | |||
|
|
ab800bbef9 | ||
|
|
15d6c7abc1 | ||
| 0b4d0e3b2f | |||
|
|
f823a409d0 | ||
|
|
e9e18f6c38 | ||
|
|
372d3c77a0 | ||
|
|
e5e0cad7c3 | ||
|
|
8668642b8d | ||
|
|
d44ee4b977 | ||
|
|
5d1d263c74 | ||
|
|
e563631efb | ||
|
|
bf42f13afc | ||
|
|
0da1ffea7f | ||
|
|
022b6d9722 | ||
|
|
665ffefd3e | ||
|
|
cc3d2dc111 | ||
|
|
ab240e42b0 | ||
|
|
b63e5e9c81 | ||
|
|
90d84ffd00 | ||
|
|
19428b4e27 | ||
|
|
316e5ef6c1 | ||
|
|
86d9ba4985 | ||
|
|
292adeea4c | ||
|
|
43a1058f33 | ||
|
|
60a800f757 | ||
|
|
76a62135ab | ||
|
|
17ba02c30d | ||
|
|
9b898924ab | ||
|
|
8de16019b7 | ||
|
|
ad2b16f26d | ||
|
|
2007a4b2da | ||
|
|
9057479da7 | ||
|
|
89c83ec7b8 | ||
|
|
b3104dc410 | ||
|
|
5bf94c6d4e | ||
|
|
40daca36a0 | ||
|
|
8c9edfdb55 | ||
|
|
25f4afcddc | ||
|
|
02be1d9264 | ||
|
|
cc7c87a520 | ||
|
|
ca19faf4f0 | ||
|
|
b86cc812b7 | ||
|
|
f0dda0d2ee | ||
|
|
3cd6bd5585 | ||
|
|
25d66af45e | ||
|
|
d783040030 | ||
|
|
6afc337b16 | ||
|
|
e881e302b6 | ||
|
|
d7ef2c488b | ||
|
|
088bc34e67 | ||
|
|
73e41e5607 | ||
|
|
f5b68c212b | ||
|
|
7c82ba93b0 | ||
|
|
1066101e8a | ||
|
|
ffb7ef0839 | ||
|
|
4dea1c6764 | ||
|
|
6c3f21d4db | ||
|
|
7a8960ca46 | ||
|
|
fdc7187424 | ||
|
|
2fd14165bc | ||
|
|
13bd03997a | ||
|
|
e64bf4f0d1 | ||
|
|
883e10ba7c | ||
|
|
0413a5b882 | ||
|
|
c6b6bafc0f | ||
|
|
988035b952 | ||
|
|
c55427c22b | ||
|
|
f681784e7e | ||
|
|
7b57ee8246 | ||
|
|
6e6e4218c9 | ||
|
|
469b36613b | ||
|
|
bcb8a040f4 | ||
|
|
d52084a081 | ||
|
|
7e7407b137 | ||
|
|
0a77080bca | ||
|
|
a5b30cd1ea | ||
|
|
ffb65edcec | ||
|
|
8b8909e488 | ||
|
|
94de4c2a5b | ||
|
|
66477ff575 | ||
|
|
6c70efcb54 | ||
|
|
1f3a9551c5 | ||
|
|
08a3ad03b7 | ||
|
|
cfcf852e2d | ||
|
|
67f7d634c9 | ||
|
|
6f984c6b78 | ||
|
|
5754b0ca81 | ||
|
|
484a388b62 | ||
|
|
d720c0500f | ||
|
|
cfa9d41b36 | ||
|
|
b974f233f4 | ||
|
|
3741ac2658 | ||
|
|
e8a726af80 | ||
|
|
53f0e55e93 | ||
|
|
06d114b46b | ||
|
|
171ed1a6ab | ||
|
|
dee1f39554 | ||
|
|
adb4ef1af8 | ||
|
|
4cc3e096b5 | ||
|
|
1d26ae481e | ||
|
|
8fe18c7f83 | ||
|
|
929e7d5aed | ||
|
|
3ab6408258 | ||
|
|
f0aa2b7d3a | ||
|
|
9bf6c17d63 | ||
|
|
1a4ae5b49b | ||
|
|
400c32a539 | ||
|
|
2cb818ec71 | ||
|
|
37668dcfe0 | ||
|
|
40ea6e5e69 | ||
|
|
6ab0a3c5a1 | ||
|
|
8130f2053d | ||
|
|
9da908e4d2 | ||
|
|
d0dba73a29 | ||
|
|
9aa535ace8 | ||
|
|
f85b5a3634 | ||
|
|
39e1b39f7a | ||
|
|
283d3e34a0 | ||
|
|
2cd15509ba | ||
|
|
9d87f71bc1 | ||
|
|
6b77a96d52 | ||
|
|
c58bf90604 | ||
|
|
273baf7996 | ||
|
|
5ca118dc93 | ||
|
|
0b8cdf6dd9 | ||
|
|
cafd7e9369 | ||
|
|
b5068250f9 | ||
|
|
0cfa359fc5 | ||
|
|
5cc9f8c9ef | ||
|
|
b066d1abe7 | ||
|
|
ae1d9fa4db | ||
|
|
6fe10432e6 | ||
|
|
9f3faf4816 | ||
|
|
a60095608e | ||
|
|
9f9112c6a5 | ||
|
|
e1a9f6d225 | ||
|
|
180644f0df | ||
|
|
62b74d2d06 | ||
|
|
3e2f035d97 | ||
|
|
9962ee99d9 | ||
|
|
b53840b77b | ||
|
|
9ed2cedc98 | ||
|
|
dc7ac3a1ec | ||
|
|
1fbafbb16d | ||
|
|
6c1241ed89 | ||
|
|
df64573bfb | ||
|
|
4526d97bda | ||
|
|
132143c083 | ||
|
|
b824942408 | ||
|
|
31e8dd05f0 | ||
|
|
eba9f560ac | ||
|
|
3c2bf4a9b1 | ||
|
|
97b2235914 | ||
|
|
338db5dcda | ||
|
|
fd50a147a2 | ||
|
|
0dd52624b7 | ||
|
|
1ce0ea411d | ||
|
|
81be25198c | ||
|
|
dc4ea33c9b | ||
|
|
186f7639ad | ||
|
|
6c7895b0d6 | ||
|
|
6170f61eeb | ||
|
|
2ed527ac74 | ||
|
|
cb1f6b8ccf | ||
|
|
758585cc9a | ||
|
|
141b44048c | ||
|
|
3c343f9441 | ||
|
|
bdb24f8de6 | ||
|
|
933b56f68f | ||
|
|
19c463051a | ||
|
|
41052d01e8 | ||
|
|
99e75b0a4e | ||
|
|
eb6897bf10 | ||
|
|
63c194dab7 | ||
|
|
44a0e413e9 | ||
|
|
15306dddc0 | ||
|
|
6eb848f353 | ||
|
|
d53afe43cc | ||
|
|
24a443ef30 | ||
|
|
d7eb700860 | ||
|
|
c1458e4995 | ||
|
|
b79a7fe405 | ||
|
|
6d6c1f3562 | ||
|
|
0e3f383cf4 | ||
|
|
cd6dd1e5af | ||
|
|
dfa2a6bfa2 | ||
|
|
a7196ff4c1 | ||
|
|
17c6723f7e | ||
|
|
91e93696ed | ||
|
|
57e41e407c | ||
|
|
bc46af5cea | ||
|
|
03fb414981 | ||
|
|
553ecc1490 | ||
|
|
dec1c53d30 | ||
|
|
ace6ad0cf2 | ||
|
|
4a67677158 | ||
|
|
27c3f4d136 | ||
|
|
fe6682e520 | ||
|
|
012c866594 | ||
|
|
4e553a6c42 | ||
|
|
f254f2700f | ||
|
|
17d8d98d5f | ||
|
|
bfb26d9aa5 | ||
|
|
cd4266ffc6 | ||
|
|
74a1e02cb8 | ||
|
|
b3a19098c5 | ||
|
|
6b1dcba876 | ||
|
|
38125f9ecc | ||
|
|
6b95cf78ea | ||
|
|
b70d95cbb9 | ||
|
|
8b9045b0e2 | ||
|
|
4fe642b91d | ||
|
|
7e13b4ee5d | ||
|
|
85eabd86ef | ||
|
|
b44f6338f8 | ||
|
|
4ff04c386e | ||
|
|
b38f02eae3 | ||
|
|
8c504b714d | ||
|
|
83801d2499 | ||
|
|
9042356e81 | ||
|
|
f97e951d87 | ||
|
|
fa6bca0add | ||
|
|
11dd6a354f | ||
|
|
7f15177310 | ||
|
|
b01f6e5109 | ||
|
|
8146f072df | ||
|
|
f13fd3faf0 | ||
|
|
5e5bc97bf5 | ||
|
|
7fc80cad58 | ||
|
|
6eabd0cf2e | ||
|
|
4debee966a | ||
|
|
1e348eb8ca | ||
|
|
f136502a35 | ||
|
|
bf367b1db7 | ||
|
|
f5165add13 | ||
|
|
ec38d0b1c2 | ||
|
|
6cd82de5f9 | ||
|
|
0a0898b2f7 | ||
|
|
6864081550 | ||
|
|
fe5838b40f | ||
|
|
1b57f03973 | ||
|
|
0a06615ae2 | ||
|
|
16a2ff3174 | ||
|
|
c2ccf9d233 | ||
|
|
06c85edd8e | ||
|
|
9514ab69c8 | ||
|
|
d3a9be8f2e | ||
|
|
85e0d6156a | ||
|
|
96aa6579b0 | ||
|
|
da4a263cd7 | ||
|
|
879accfc7f | ||
|
|
35a62463b3 | ||
|
|
92503a1061 | ||
|
|
95a92ae9e5 | ||
|
|
5aa8586940 | ||
|
|
776a01d87b | ||
|
|
0b736a92f9 | ||
|
|
df90814cc3 | ||
|
|
8cf44f6e2c | ||
|
|
5e69628a51 | ||
|
|
9163f919c8 | ||
|
|
3b8b76d53e | ||
|
|
e5523c969e | ||
|
|
e2e5c794a2 | ||
|
|
d5eead888d | ||
|
|
4121bd64b2 | ||
|
|
dd8553a8b4 | ||
|
|
3284304c1f | ||
|
|
6f8b84fb1a | ||
|
|
d2caa737b9 | ||
|
|
875b07fb3a | ||
|
|
4fdf171912 | ||
|
|
2239d3d980 | ||
|
|
8eef7e170b | ||
|
|
d7ce0aaf8c | ||
|
|
a0c12b8ee6 | ||
|
|
a5445e332e | ||
|
|
cab6e409b9 | ||
|
|
0fe084bcb2 | ||
|
|
3ae8fa18cd | ||
|
|
82f62ca0ff | ||
|
|
dd30ee77d4 | ||
|
|
a3a6f99958 | ||
|
|
22752ffcb1 | ||
|
|
a48c4bfd08 | ||
|
|
45bcc954ac | ||
|
|
51a1aef10e | ||
|
|
2607ef5dbe | ||
|
|
0a1e848ef7 | ||
|
|
6dc5e558a3 | ||
|
|
a3a1643b37 | ||
|
|
4447d79c92 | ||
|
|
7e7a07470b | ||
|
|
252c18bcff | ||
|
|
269c679e9c | ||
|
|
e559267f1e | ||
|
|
4341656a5e | ||
|
|
2cda065c06 | ||
|
|
bcad83cc40 | ||
|
|
0d47c2ec7c | ||
|
|
247ec030e5 | ||
|
|
a1acc0bc62 | ||
|
|
8b94937d38 | ||
|
|
1750fe64a2 | ||
|
|
4572a4bb57 | ||
|
|
9824d06824 | ||
|
|
e24c6da025 | ||
|
|
6bdcbf840b | ||
|
|
4699db5465 | ||
|
|
d911fd2201 | ||
|
|
b4f9277220 | ||
|
|
eaf109549d | ||
|
|
3a6b94c1eb | ||
|
|
b727bc771d | ||
|
|
7ee2985626 | ||
|
|
3efae43879 | ||
|
|
aa663a9c9e | ||
|
|
f5ef8e6488 | ||
|
|
0a43a7dcd1 | ||
|
|
3b345881c6 | ||
|
|
2dc75c4361 | ||
|
|
b7a0530466 | ||
|
|
ebdb4f9450 | ||
|
|
5ed33807d8 | ||
|
|
00476c974f | ||
|
|
c674785c82 | ||
|
|
4087ce8f29 | ||
|
|
39c3b39711 | ||
|
|
cdd495d985 | ||
|
|
17fbe73e60 | ||
|
|
faac0048c3 | ||
|
|
e6f2f17fa1 | ||
|
|
28d044efbc | ||
|
|
6a81053d37 | ||
|
|
fd41a056eb | ||
|
|
9ecaf22f09 | ||
|
|
d2f6b02a5f | ||
|
|
bf3aa57274 | ||
|
|
e56e3fca8a | ||
|
|
127834ce4d | ||
|
|
6bdb02ff5a | ||
|
|
96a5b1d9f1 | ||
|
|
771e9d1081 | ||
|
|
ebba021448 | ||
|
|
81d570fd63 | ||
|
|
7b92de4017 | ||
| 0ba896ada4 | |||
|
|
af7abc3eac | ||
|
|
ce1655bba6 | ||
|
|
798ec4850d | ||
|
|
7d4126ad4e | ||
|
|
e3d9a3bd18 | ||
|
|
7c7d574aa7 | ||
|
|
f9b1628e14 | ||
|
|
e84e53f835 | ||
|
|
1133763520 | ||
|
|
5c4a84e64c |
65
.env.example
@@ -1,32 +1,55 @@
|
|||||||
# Cameleer SaaS Environment Variables
|
# Cameleer SaaS — Environment Configuration
|
||||||
# Copy to .env and fill in values
|
# Copy to .env and fill in values for production
|
||||||
|
|
||||||
# Application version
|
# Image version
|
||||||
VERSION=latest
|
VERSION=latest
|
||||||
|
|
||||||
|
# Public access
|
||||||
|
PUBLIC_HOST=localhost
|
||||||
|
PUBLIC_PROTOCOL=https
|
||||||
|
# Auth domain (Logto). Defaults to PUBLIC_HOST for single-domain setups.
|
||||||
|
# Set to a separate subdomain (e.g. auth.cameleer.io) to split auth from the app.
|
||||||
|
# AUTH_HOST=localhost
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
LOGTO_CONSOLE_PORT=3002
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_USER=cameleer
|
POSTGRES_USER=cameleer
|
||||||
POSTGRES_PASSWORD=change_me_in_production
|
POSTGRES_PASSWORD=change_me_in_production
|
||||||
POSTGRES_DB=cameleer_saas
|
POSTGRES_DB=cameleer_saas
|
||||||
|
|
||||||
# Logto Identity Provider
|
# ClickHouse
|
||||||
LOGTO_ENDPOINT=http://logto:3001
|
CLICKHOUSE_PASSWORD=change_me_in_production
|
||||||
LOGTO_PUBLIC_ENDPOINT=http://localhost:3001
|
|
||||||
LOGTO_ISSUER_URI=http://localhost:3001/oidc
|
|
||||||
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
|
|
||||||
LOGTO_DB_PASSWORD=change_me_in_production
|
|
||||||
LOGTO_M2M_CLIENT_ID=
|
|
||||||
LOGTO_M2M_CLIENT_SECRET=
|
|
||||||
LOGTO_SPA_CLIENT_ID=
|
|
||||||
|
|
||||||
# Ed25519 Keys (mount PEM files)
|
# Admin user (created by bootstrap)
|
||||||
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
|
# In SaaS mode, this must be an email address (primary user identity).
|
||||||
CAMELEER_JWT_PUBLIC_KEY_PATH=/etc/cameleer/keys/ed25519.pub
|
# In standalone mode, any username is accepted.
|
||||||
|
SAAS_ADMIN_USER=admin@example.com
|
||||||
|
SAAS_ADMIN_PASS=change_me_in_production
|
||||||
|
|
||||||
# Domain (for Traefik TLS)
|
# SMTP / email connector configuration is managed at runtime via the vendor
|
||||||
DOMAIN=localhost
|
# admin UI (Email Connector page at /vendor/email). No SMTP env vars needed.
|
||||||
|
|
||||||
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
|
# TLS (leave empty for self-signed)
|
||||||
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
||||||
CAMELEER_CONTAINER_CPU_SHARES=512
|
# CERT_FILE=
|
||||||
CAMELEER_TENANT_SLUG=default
|
# KEY_FILE=
|
||||||
|
# CA_FILE=
|
||||||
|
|
||||||
|
# Vendor account (optional)
|
||||||
|
VENDOR_SEED_ENABLED=false
|
||||||
|
# VENDOR_USER=vendor
|
||||||
|
# VENDOR_PASS=change_me
|
||||||
|
|
||||||
|
# Docker socket GID (run: stat -c '%g' /var/run/docker.sock)
|
||||||
|
# DOCKER_GID=0
|
||||||
|
|
||||||
|
# Docker images (override for custom registries)
|
||||||
|
# TRAEFIK_IMAGE=gitea.siegeln.net/cameleer/cameleer-traefik
|
||||||
|
# POSTGRES_IMAGE=gitea.siegeln.net/cameleer/cameleer-postgres
|
||||||
|
# CLICKHOUSE_IMAGE=gitea.siegeln.net/cameleer/cameleer-clickhouse
|
||||||
|
# LOGTO_IMAGE=gitea.siegeln.net/cameleer/cameleer-logto
|
||||||
|
# CAMELEER_IMAGE=gitea.siegeln.net/cameleer/cameleer-saas
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Test (unit tests only)
|
- name: Build and Test (unit tests only)
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify -B
|
mvn clean verify -U -B
|
||||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
|
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.java"
|
||||||
|
|
||||||
- name: Build sign-in UI
|
- name: Build sign-in UI
|
||||||
run: |
|
run: |
|
||||||
@@ -111,12 +111,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push runtime base image
|
- name: Build and push runtime base image
|
||||||
run: |
|
run: |
|
||||||
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/maven-metadata.xml" \
|
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-agent/1.0-SNAPSHOT/maven-metadata.xml" \
|
||||||
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
|
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
|
||||||
echo "Agent version: $AGENT_VERSION"
|
echo "Agent version: $AGENT_VERSION"
|
||||||
curl -sf -o docker/runtime-base/agent.jar \
|
curl -sf -o docker/runtime-base/agent.jar \
|
||||||
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/cameleer3-agent-${AGENT_VERSION}-shaded.jar"
|
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-agent/1.0-SNAPSHOT/cameleer-agent-${AGENT_VERSION}-shaded.jar"
|
||||||
ls -la docker/runtime-base/agent.jar
|
APPENDER_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-log-appender/1.0-SNAPSHOT/maven-metadata.xml" \
|
||||||
|
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
|
||||||
|
echo "Log appender version: $APPENDER_VERSION"
|
||||||
|
curl -sf -o docker/runtime-base/cameleer-log-appender.jar \
|
||||||
|
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-log-appender/1.0-SNAPSHOT/cameleer-log-appender-${APPENDER_VERSION}.jar"
|
||||||
|
ls -la docker/runtime-base/agent.jar docker/runtime-base/cameleer-log-appender.jar
|
||||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer-runtime-base:${{ github.sha }}"
|
TAGS="-t gitea.siegeln.net/cameleer/cameleer-runtime-base:${{ github.sha }}"
|
||||||
for TAG in $IMAGE_TAGS; do
|
for TAG in $IMAGE_TAGS; do
|
||||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-runtime-base:$TAG"
|
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-runtime-base:$TAG"
|
||||||
@@ -139,6 +144,39 @@ jobs:
|
|||||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache \
|
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache \
|
||||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache,mode=max \
|
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache,mode=max \
|
||||||
--provenance=false \
|
--provenance=false \
|
||||||
--push ui/sign-in/
|
--push .
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push PostgreSQL image
|
||||||
|
run: |
|
||||||
|
TAGS="-t gitea.siegeln.net/cameleer/cameleer-postgres:${{ github.sha }}"
|
||||||
|
for TAG in $IMAGE_TAGS; do
|
||||||
|
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-postgres:$TAG"
|
||||||
|
done
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
$TAGS \
|
||||||
|
--provenance=false \
|
||||||
|
--push docker/cameleer-postgres/
|
||||||
|
|
||||||
|
- name: Build and push ClickHouse image
|
||||||
|
run: |
|
||||||
|
TAGS="-t gitea.siegeln.net/cameleer/cameleer-clickhouse:${{ github.sha }}"
|
||||||
|
for TAG in $IMAGE_TAGS; do
|
||||||
|
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-clickhouse:$TAG"
|
||||||
|
done
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
$TAGS \
|
||||||
|
--provenance=false \
|
||||||
|
--push docker/cameleer-clickhouse/
|
||||||
|
|
||||||
|
- name: Build and push Traefik image
|
||||||
|
run: |
|
||||||
|
TAGS="-t gitea.siegeln.net/cameleer/cameleer-traefik:${{ github.sha }}"
|
||||||
|
for TAG in $IMAGE_TAGS; do
|
||||||
|
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-traefik:$TAG"
|
||||||
|
done
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
$TAGS \
|
||||||
|
--provenance=false \
|
||||||
|
--push docker/cameleer-traefik/
|
||||||
|
|||||||
9
.gitignore
vendored
@@ -22,6 +22,15 @@ Thumbs.db
|
|||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude/
|
||||||
|
.superpowers/
|
||||||
|
.playwright-mcp/
|
||||||
|
.gitnexus
|
||||||
|
|
||||||
|
# Installer output (generated by install.sh / install.ps1)
|
||||||
|
installer/cameleer/
|
||||||
|
|
||||||
# Generated by postinstall from @cameleer/design-system
|
# Generated by postinstall from @cameleer/design-system
|
||||||
ui/public/favicon.svg
|
ui/public/favicon.svg
|
||||||
docker/runtime-base/agent.jar
|
docker/runtime-base/agent.jar
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "installer"]
|
||||||
|
path = installer
|
||||||
|
url = https://gitea.siegeln.net/cameleer/cameleer-saas-installer.git
|
||||||
101
AGENTS.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **cameleer-saas** (3458 symbols, 7429 relationships, 292 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/cameleer-saas/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/cameleer-saas/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/cameleer-saas/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/cameleer-saas/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/cameleer-saas/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
321
CLAUDE.md
@@ -4,206 +4,61 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Cameleer SaaS — multi-tenant SaaS platform wrapping the Cameleer observability stack (Java agent + server) for Apache Camel applications. Customers get managed observability for their Camel integrations without running infrastructure.
|
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Three personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance; **new user** (authenticated, no scopes) goes through self-service onboarding. Tenants can be created by the vendor OR via self-service sign-up (email registration + onboarding wizard). Each tenant gets per-tenant cameleer-server + UI instances via Docker API.
|
||||||
|
|
||||||
|
**Email is the primary user identity** in SaaS mode. `SAAS_ADMIN_USER` IS the email address — there is no separate `SAAS_ADMIN_EMAIL`. The installer enforces email format in SaaS mode (must contain `@`; auto-appends `@<PUBLIC_HOST>` if missing). The bootstrap uses `SAAS_ADMIN_USER` as both the Logto username and primaryEmail. In standalone mode, any username is accepted. Self-service registration (email + password + verification code) is disabled by default and enabled via the vendor UI after configuring an email connector.
|
||||||
|
|
||||||
## Ecosystem
|
## Ecosystem
|
||||||
|
|
||||||
This repo is the SaaS layer on top of two proven components:
|
This repo is the SaaS layer on top of two proven components:
|
||||||
|
|
||||||
- **cameleer3** (sibling repo) — Java agent using ByteBuddy for zero-code instrumentation of Camel apps. Captures route executions, processor traces, payloads, metrics, and route graph topology. Deploys as `-javaagent` JAR.
|
- **cameleer** (sibling repo) — Java agent using ByteBuddy for zero-code instrumentation of Camel apps. Captures route executions, processor traces, payloads, metrics, and route graph topology. Deploys as `-javaagent` JAR.
|
||||||
- **cameleer3-server** (sibling repo) — Spring Boot observability backend. Receives agent data via HTTP, pushes config/commands via SSE. PostgreSQL + ClickHouse storage. React SPA dashboard. JWT auth with Ed25519 config signing. Docker container orchestration for app deployments.
|
- **cameleer-server** (sibling repo) — Spring Boot observability backend. Receives agent data via HTTP, pushes config/commands via SSE. PostgreSQL + ClickHouse storage. React SPA dashboard. JWT auth with Ed25519 config signing. Docker container orchestration for app deployments.
|
||||||
- **cameleer-website** — Marketing site (Astro 5)
|
- **cameleer-website** — Marketing site (Astro 5)
|
||||||
- **design-system** — Shared React component library (`@cameleer/design-system` on Gitea npm registry)
|
- **design-system** — Shared React component library (`@cameleer/design-system` on Gitea npm registry)
|
||||||
|
|
||||||
Agent-server protocol is defined in `cameleer3/cameleer3-common/PROTOCOL.md`. The agent and server are mature, proven components — this repo wraps them with multi-tenancy, billing, and self-service onboarding.
|
Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The agent and server are mature, proven components — this repo wraps them with multi-tenancy, billing, and self-service onboarding.
|
||||||
|
|
||||||
## Key Classes
|
## Key Packages
|
||||||
|
|
||||||
### Java Backend (`src/main/java/net/siegeln/cameleer/saas/`)
|
### Java Backend (`src/main/java/net/siegeln/cameleer/saas/`)
|
||||||
|
|
||||||
**config/** — Security, tenant isolation, web config
|
| Package | Purpose | Key classes |
|
||||||
- `SecurityConfig.java` — OAuth2 JWT decoder (ES384, issuer/audience validation, scope extraction)
|
|---------|---------|-------------|
|
||||||
- `TenantIsolationInterceptor.java` — HandlerInterceptor on `/api/**`; JWT org_id -> TenantContext, path variable validation, fail-closed
|
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
||||||
- `TenantContext.java` — ThreadLocal<UUID> tenant ID storage
|
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||||
- `WebConfig.java` — registers TenantIsolationInterceptor
|
| `account/` | Shared user account operations | `AccountService` (profile, password, MFA, passkeys), `AccountController` (`/api/account/*`) |
|
||||||
- `PublicConfigController.java` — GET /api/config (Logto endpoint, SPA client ID, scopes)
|
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController`, `VendorAuthPolicyController`, `VendorAuthPolicyEntity`, `VendorAdminService`, `VendorAdminController` |
|
||||||
- `MeController.java` — GET /api/me (authenticated user, tenant list)
|
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||||
|
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService` (delegates user-level ops to AccountService), `TenantPortalController` |
|
||||||
|
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||||
|
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
||||||
|
| `license/` | License management | `LicenseService`, `LicenseController` |
|
||||||
|
| `identity/` | Logto & server integration | `LogtoManagementClient`, `ServerApiClient` |
|
||||||
|
| `audit/` | Audit logging | `AuditService` |
|
||||||
|
|
||||||
**tenant/** — Tenant lifecycle
|
### Frontend
|
||||||
- `TenantEntity.java` — JPA entity (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
|
|
||||||
- `TenantService.java` — create tenant -> Logto org, activate, suspend
|
|
||||||
- `TenantController.java` — POST create, GET list, GET by ID
|
|
||||||
|
|
||||||
**license/** — License management
|
- **`ui/src/`** — React 19 SPA at `/platform/*` (vendor + tenant admin pages)
|
||||||
- `LicenseEntity.java` — JPA entity (id, tenant_id, tier, features JSONB, limits JSONB, expires_at)
|
- **`ui/sign-in/`** — Custom Logto sign-in UI (built into `cameleer-logto` Docker image)
|
||||||
- `LicenseService.java` — generation, validation, feature/limit lookups
|
|
||||||
- `LicenseController.java` — POST issue, GET verify, DELETE revoke
|
|
||||||
|
|
||||||
**identity/** — Logto & server integration
|
|
||||||
- `LogtoConfig.java` — Logto endpoint, M2M credentials (reads from bootstrap file)
|
|
||||||
- `LogtoManagementClient.java` — Logto Management API calls (create org, create user, add to org)
|
|
||||||
- `ServerApiClient.java` — M2M client for cameleer3-server API (Logto M2M token, `X-Cameleer-Protocol-Version: 1` header)
|
|
||||||
|
|
||||||
**audit/** — Audit logging
|
|
||||||
- `AuditEntity.java` — JPA entity (actor_id, tenant_id, action, resource, status)
|
|
||||||
- `AuditService.java` — log audit events (TENANT_CREATE, TENANT_UPDATE, etc.)
|
|
||||||
|
|
||||||
### React Frontend (`ui/src/`)
|
|
||||||
|
|
||||||
- `main.tsx` — React 19 root
|
|
||||||
- `router.tsx` — /login, /callback, / -> OrgResolver -> Layout -> pages
|
|
||||||
- `config.ts` — fetch Logto config from /platform/api/config
|
|
||||||
- `auth/useAuth.ts` — auth hook (isAuthenticated, logout, signIn)
|
|
||||||
- `auth/useOrganization.ts` — Zustand store for current tenant
|
|
||||||
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
|
||||||
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
|
||||||
- `pages/DashboardPage.tsx` — tenant dashboard
|
|
||||||
- `pages/LicensePage.tsx` — license info
|
|
||||||
- `pages/AdminTenantsPage.tsx` — platform admin tenant management
|
|
||||||
|
|
||||||
### Custom Sign-in UI (`ui/sign-in/src/`)
|
|
||||||
|
|
||||||
- `SignInPage.tsx` — form with @cameleer/design-system components
|
|
||||||
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
|
|
||||||
|
|
||||||
## Architecture Context
|
## Architecture Context
|
||||||
|
|
||||||
The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstrap tokens, OIDC). The SaaS layer must:
|
The SaaS platform is a **vendor management plane**. It does not proxy requests to servers — instead it provisions dedicated per-tenant cameleer-server instances via Docker API. Each tenant gets isolated server + UI containers with their own database schemas, networks, and Traefik routing.
|
||||||
- Add multi-tenancy (tenant isolation of agent data, diagrams, configs)
|
|
||||||
- Provide self-service signup, billing, and team management
|
|
||||||
- Generate per-tenant bootstrap tokens for agent registration
|
|
||||||
- Proxy or federate access to tenant-specific cameleer3-server instances
|
|
||||||
- Enforce usage quotas and metered billing
|
|
||||||
|
|
||||||
### Routing (single-domain, path-based via Traefik)
|
For detailed architecture docs, see the directory-scoped CLAUDE.md files (loaded automatically when editing code in that directory):
|
||||||
|
- **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md`
|
||||||
All services on one hostname. Two env vars control everything: `PUBLIC_HOST` + `PUBLIC_PROTOCOL`.
|
- **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md`
|
||||||
|
- **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md`
|
||||||
| Path | Target | Notes |
|
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md` (git submodule: `cameleer-saas-installer`)
|
||||||
|------|--------|-------|
|
- **Frontend, sign-in UI** → `ui/CLAUDE.md`
|
||||||
| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) |
|
|
||||||
| `/server/*` | cameleer3-server-ui:80 | Server dashboard (strip-prefix + `BASE_PATH=/server`) |
|
|
||||||
| `/` | redirect -> `/platform/` | Via `docker/traefik-dynamic.yml` |
|
|
||||||
| `/*` (catch-all) | cameleer-logto:3001 (priority=1) | Custom sign-in UI, OIDC, interaction |
|
|
||||||
|
|
||||||
- SPA assets at `/_app/` (Vite `assetsDir: '_app'`) to avoid conflict with Logto's `/assets/`
|
|
||||||
- Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain, same origin)
|
|
||||||
- TLS: self-signed cert init container (`traefik-certs`) for dev, ACME for production
|
|
||||||
- Root `/` -> `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`)
|
|
||||||
- LoginPage auto-redirects to Logto OIDC (no intermediate button)
|
|
||||||
|
|
||||||
### Docker Networks
|
|
||||||
|
|
||||||
Two networks in docker-compose.yml:
|
|
||||||
|
|
||||||
| Network | Name on Host | Purpose |
|
|
||||||
|---------|-------------|---------|
|
|
||||||
| `cameleer` | `cameleer-saas_cameleer` | Compose default — all services (DB, Logto, SaaS, server) |
|
|
||||||
| `cameleer-traefik` | `cameleer-traefik` (fixed `name:`) | Traefik + server + deployed app containers |
|
|
||||||
|
|
||||||
The `cameleer-traefik` network uses `name: cameleer-traefik` (no compose project prefix) so `DockerNetworkManager.ensureNetwork("cameleer-traefik")` in the server finds it. The server joins with DNS alias `cameleer3-server`, matching `CAMELEER_SERVER_URL=http://cameleer3-server:8081`. Per-environment networks (`cameleer-env-{slug}`) are created dynamically by the server's `DockerNetworkManager`.
|
|
||||||
|
|
||||||
### Custom sign-in UI (`ui/sign-in/`)
|
|
||||||
|
|
||||||
Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches cameleer3-server LoginPage.
|
|
||||||
|
|
||||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + COPY dist over `/etc/logto/packages/experience/dist/`
|
|
||||||
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
|
||||||
- Authenticates via Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect)
|
|
||||||
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
|
||||||
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
|
||||||
|
|
||||||
### Auth enforcement
|
|
||||||
|
|
||||||
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
|
|
||||||
- Tenant isolation enforced by `TenantIsolationInterceptor` (a single `HandlerInterceptor` on `/api/**` that resolves JWT org_id to TenantContext and validates `{tenantId}`, `{environmentId}`, `{appId}` path variables; fail-closed, platform admins bypass)
|
|
||||||
- 13 OAuth2 scopes on the Logto API resource (`https://api.cameleer.local`): 10 platform scopes + 3 server scopes (`server:admin`, `server:operator`, `server:viewer`), served to the frontend from `GET /platform/api/config`
|
|
||||||
- Server scopes map to server RBAC roles via JWT `scope` claim (SaaS platform path) or `roles` claim (server-ui OIDC login path)
|
|
||||||
- 4-role model: `saas-vendor` (global, hosted only), org `owner` -> `server:admin`, org `operator` -> `server:operator`, org `viewer` -> `server:viewer`
|
|
||||||
- `saas-vendor` global role injected via `docker/vendor-seed.sh` (not standard bootstrap) — has `platform:admin` + all tenant scopes
|
|
||||||
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
|
|
||||||
- Logto Custom JWT (Phase 7b in bootstrap) injects a `roles` claim into access tokens based on org roles and global roles — this makes role data available to the server without Logto-specific code
|
|
||||||
|
|
||||||
### Server integration (cameleer3-server env vars)
|
|
||||||
|
|
||||||
| Env var | Value | Purpose |
|
|
||||||
|---------|-------|---------|
|
|
||||||
| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation |
|
|
||||||
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
|
|
||||||
| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev only — disable in production) |
|
|
||||||
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik |
|
|
||||||
| `BASE_PATH` (server-ui) | `/server` | React Router basename + `<base>` tag |
|
|
||||||
|
|
||||||
### Server runtime env vars (docker-compose.dev.yml)
|
|
||||||
|
|
||||||
| Env var | Value | Purpose |
|
|
||||||
|---------|-------|---------|
|
|
||||||
| `CAMELEER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
|
|
||||||
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | Where JARs are stored inside server container |
|
|
||||||
| `CAMELEER_RUNTIME_BASE_IMAGE` | `gitea.siegeln.net/cameleer/cameleer-runtime-base:latest` | Base image for deployed apps |
|
|
||||||
| `CAMELEER_SERVER_URL` | `http://cameleer3-server:8081` | Server URL agents connect to |
|
|
||||||
| `CAMELEER_ROUTING_DOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing labels |
|
|
||||||
| `CAMELEER_ROUTING_MODE` | `path` | `path` or `subdomain` routing |
|
|
||||||
| `CAMELEER_JAR_DOCKER_VOLUME` | `cameleer-saas_jardata` | Named volume for Docker-in-Docker JAR mounting |
|
|
||||||
|
|
||||||
### Server OIDC role extraction (two paths)
|
|
||||||
|
|
||||||
| Path | Token type | Role source | How it works |
|
|
||||||
|------|-----------|-------------|--------------|
|
|
||||||
| SaaS platform -> server API | Logto org-scoped access token | `scope` claim | `JwtAuthenticationFilter.extractRolesFromScopes()` reads `server:admin` from scope |
|
|
||||||
| Server-ui SSO login | Logto JWT access token (via Traditional Web App) | `roles` claim | `OidcTokenExchanger` decodes access_token, reads `roles` injected by Custom JWT |
|
|
||||||
|
|
||||||
The server's OIDC config (`OidcConfig`) includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. The `audience` is sent as `resource` in both the authorization request and token exchange, which makes Logto return a JWT access token instead of opaque. The Custom JWT script maps org roles to `roles: ["server:admin"]`. If OIDC returns no roles and the user already exists, `syncOidcRoles` preserves existing local roles.
|
|
||||||
|
|
||||||
### Deployment pipeline
|
|
||||||
|
|
||||||
App deployment is handled by the cameleer3-server's `DeploymentExecutor` (7-stage async flow):
|
|
||||||
1. PRE_FLIGHT — validate config, check JAR exists
|
|
||||||
2. PULL_IMAGE — pull base image if missing
|
|
||||||
3. CREATE_NETWORK — ensure cameleer-traefik and cameleer-env-{slug} networks
|
|
||||||
4. START_REPLICAS — create N containers with Traefik labels
|
|
||||||
5. HEALTH_CHECK — poll `/cameleer/health` on agent port 9464
|
|
||||||
6. SWAP_TRAFFIC — stop old deployment (blue/green)
|
|
||||||
7. COMPLETE — mark RUNNING or DEGRADED
|
|
||||||
|
|
||||||
Key files:
|
|
||||||
- `DeploymentExecutor.java` (in cameleer3-server) — async staged deployment
|
|
||||||
- `DockerRuntimeOrchestrator.java` (in cameleer3-server) — Docker client, container lifecycle
|
|
||||||
- `docker/runtime-base/Dockerfile` — base image with agent JAR, maps env vars to `-D` system properties
|
|
||||||
- `ServerApiClient.java` — M2M token acquisition for SaaS->server API calls (agent status). Uses `X-Cameleer-Protocol-Version: 1` header
|
|
||||||
- Docker socket access: `group_add: ["0"]` in docker-compose.dev.yml (not root group membership in Dockerfile)
|
|
||||||
- Network: deployed containers join `cameleer-traefik` (routing) + `cameleer-env-{slug}` (isolation)
|
|
||||||
|
|
||||||
### Bootstrap (`docker/logto-bootstrap.sh`)
|
|
||||||
|
|
||||||
Idempotent script run via `logto-bootstrap` init container. Phases:
|
|
||||||
1. Wait for Logto + server health
|
|
||||||
2. Get Management API token (reads `m-default` secret from DB)
|
|
||||||
3. Create Logto apps (SPA, Traditional with `skipConsent`, M2M with Management API role + server API role)
|
|
||||||
3b. Create API resource scopes (10 platform + 3 server scopes)
|
|
||||||
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
|
||||||
5. Create users (platform owner with Logto console access, viewer for testing read-only OIDC)
|
|
||||||
6. Create organization, add users with org roles (owner + viewer)
|
|
||||||
7. Configure cameleer3-server OIDC (`rolesClaim: "roles"`, `audience`, `defaultRoles: ["VIEWER"]`)
|
|
||||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: admin->server:admin, member->server:viewer)
|
|
||||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
|
||||||
9. Cleanup seeded Logto apps
|
|
||||||
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
|
||||||
|
|
||||||
Platform owner credentials (`SAAS_ADMIN_USER`/`SAAS_ADMIN_PASS`) work for both the SaaS platform and the Logto console (port 3002). The `saas-vendor` global role (hosted only) is created separately via `docker/vendor-seed.sh`.
|
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
|
|
||||||
PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
||||||
- V001 — tenants (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
|
- V001 — consolidated baseline: tenants (with db_password, server_endpoint, provision_error, ca_applied_at), licenses, audit_log, certificates, tenant_ca_certs
|
||||||
- V002 — licenses (id, tenant_id, tier, features JSONB, limits JSONB, expires_at)
|
- V002 — license minter: signing_keys table, tier renames, license label + grace period
|
||||||
- V003 — environments (tenant -> environments 1:N)
|
- V003 — passkey MFA: vendor_auth_policy single-row config table (mfa_mode, passkey_enabled, passkey_mode)
|
||||||
- V004 — api_keys (auth tokens for agent registration)
|
|
||||||
- V005 — apps (Camel applications)
|
|
||||||
- V006 — deployments (app versions, deployment history)
|
|
||||||
- V007 — audit_log
|
|
||||||
- V008 — app resource limits
|
|
||||||
- V010 — cleanup of migrated tables
|
|
||||||
|
|
||||||
## Related Conventions
|
## Related Conventions
|
||||||
|
|
||||||
@@ -211,13 +66,117 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
|||||||
- CI: `.gitea/workflows/` — Gitea Actions
|
- CI: `.gitea/workflows/` — Gitea Actions
|
||||||
- K8s target: k3s cluster at 192.168.50.86
|
- K8s target: k3s cluster at 192.168.50.86
|
||||||
- Docker images: CI builds and pushes all images — Dockerfiles use multi-stage builds, no local builds needed
|
- Docker images: CI builds and pushes all images — Dockerfiles use multi-stage builds, no local builds needed
|
||||||
- `cameleer-saas` — SaaS app (frontend + JAR baked in)
|
- `cameleer-saas` — SaaS vendor management plane (frontend + JAR baked in)
|
||||||
- `cameleer-logto` — custom Logto with sign-in UI baked in
|
- `cameleer-logto` — custom Logto with sign-in UI baked in
|
||||||
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + JRE). CI downloads latest agent SNAPSHOT from Gitea Maven registry. Uses `CAMELEER_SERVER_URL` env var (not CAMELEER_EXPORT_ENDPOINT).
|
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
|
||||||
|
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
|
||||||
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
|
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
|
||||||
- `docker-compose.dev.yml` — exposes ports for direct access, sets `SPRING_PROFILES_ACTIVE: dev`. Volume-mounts `./ui/dist` into the container so local UI builds are served without rebuilding the Docker image (`SPRING_WEB_RESOURCES_STATIC_LOCATIONS` overrides classpath). Adds Docker socket mount, jardata volume, and runtime env vars for container orchestration.
|
- `docker-compose.yml` (root) — thin dev overlay (ports, volume mounts, `SPRING_PROFILES_ACTIVE: dev`). Chained on top of production templates from the installer submodule via `COMPOSE_FILE` in `.env`.
|
||||||
|
- Installer is a **git submodule** at `installer/` pointing to `cameleer/cameleer-saas-installer` (public repo). Compose templates live there — single source of truth, no duplication. Run `git submodule update --remote installer` to pull template updates.
|
||||||
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
|
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
|
||||||
|
|
||||||
## Disabled Skills
|
## Disabled Skills
|
||||||
|
|
||||||
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **vendor-admin-account** (3510 symbols, 7678 relationships, 298 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/vendor-admin-account/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/vendor-admin-account/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/vendor-admin-account/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/vendor-admin-account/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/vendor-admin-account/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ WORKDIR /build
|
|||||||
COPY .mvn/ .mvn/
|
COPY .mvn/ .mvn/
|
||||||
COPY mvnw pom.xml ./
|
COPY mvnw pom.xml ./
|
||||||
# Cache deps — BuildKit cache mount persists across --no-cache builds
|
# Cache deps — BuildKit cache mount persists across --no-cache builds
|
||||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B || true
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -U -B || true
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY --from=frontend /ui/dist/ src/main/resources/static/
|
COPY --from=frontend /ui/dist/ src/main/resources/static/
|
||||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -U -B
|
||||||
|
|
||||||
# Runtime: target platform (amd64)
|
# Runtime: target platform (amd64)
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
|||||||
92
HOWTO.md
@@ -35,19 +35,21 @@ curl http://localhost:8080/actuator/health
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The platform runs as a Docker Compose stack with 6 services:
|
The platform runs as a Docker Compose stack:
|
||||||
|
|
||||||
| Service | Image | Port | Purpose |
|
| Service | Image | Port | Purpose |
|
||||||
|---------|-------|------|---------|
|
|---------|-------|------|---------|
|
||||||
| **traefik** | traefik:v3 | 80, 443 | Reverse proxy, TLS, routing |
|
| **traefik-certs** | alpine:latest | — | Init container: generates self-signed cert or copies user-supplied cert |
|
||||||
|
| **traefik** | traefik:v3 | 80, 443, 3002 | Reverse proxy, TLS termination, routing |
|
||||||
| **postgres** | postgres:16-alpine | 5432* | Platform database + Logto database |
|
| **postgres** | postgres:16-alpine | 5432* | Platform database + Logto database |
|
||||||
| **logto** | ghcr.io/logto-io/logto | 3001*, 3002* | Identity provider (OIDC) |
|
| **logto** | ghcr.io/logto-io/logto | 3001*, 3002* | Identity provider (OIDC) |
|
||||||
| **cameleer-saas** | cameleer-saas:latest | 8080* | SaaS API server |
|
| **cameleer-saas** | cameleer-saas:latest | 8080* | SaaS API server + vendor UI |
|
||||||
| **cameleer3-server** | cameleer3-server:latest | 8081 | Observability backend |
|
|
||||||
| **clickhouse** | clickhouse-server:latest | 8123* | Trace/metrics/log storage |
|
| **clickhouse** | clickhouse-server:latest | 8123* | Trace/metrics/log storage |
|
||||||
|
|
||||||
*Ports exposed to host only with `docker-compose.dev.yml` overlay.
|
*Ports exposed to host only with `docker-compose.dev.yml` overlay.
|
||||||
|
|
||||||
|
Per-tenant `cameleer-server` and `cameleer-server-ui` containers are provisioned dynamically by `DockerTenantProvisioner` — they are NOT part of the compose stack.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### 1. Environment Configuration
|
### 1. Environment Configuration
|
||||||
@@ -61,12 +63,10 @@ Edit `.env` and set at minimum:
|
|||||||
```bash
|
```bash
|
||||||
# Change in production
|
# Change in production
|
||||||
POSTGRES_PASSWORD=<strong-password>
|
POSTGRES_PASSWORD=<strong-password>
|
||||||
CAMELEER_AUTH_TOKEN=<random-string-for-agent-bootstrap>
|
|
||||||
CAMELEER_TENANT_SLUG=<your-tenant-slug> # e.g., "acme" — tags all observability data
|
|
||||||
|
|
||||||
# Logto M2M credentials (get from Logto admin console after first boot)
|
# Logto M2M credentials (auto-provisioned by bootstrap, or get from Logto admin console)
|
||||||
LOGTO_M2M_CLIENT_ID=
|
CAMELEER_SAAS_IDENTITY_M2MCLIENTID=
|
||||||
LOGTO_M2M_CLIENT_SECRET=
|
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET=
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Ed25519 Keys
|
### 2. Ed25519 Keys
|
||||||
@@ -83,7 +83,25 @@ This creates `keys/ed25519.key` (private) and `keys/ed25519.pub` (public). The k
|
|||||||
|
|
||||||
If no key files are configured, the platform generates ephemeral keys on startup (suitable for development only -- keys change on every restart).
|
If no key files are configured, the platform generates ephemeral keys on startup (suitable for development only -- keys change on every restart).
|
||||||
|
|
||||||
### 3. Start the Stack
|
### 3. TLS Certificate (Optional)
|
||||||
|
|
||||||
|
By default, the `traefik-certs` init container generates a self-signed certificate for `PUBLIC_HOST`. To supply your own certificate at bootstrap time, set these env vars in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CERT_FILE=/path/to/cert.pem # PEM-encoded certificate
|
||||||
|
KEY_FILE=/path/to/key.pem # PEM-encoded private key
|
||||||
|
CA_FILE=/path/to/ca.pem # Optional: CA bundle (for private CA trust)
|
||||||
|
```
|
||||||
|
|
||||||
|
The init container validates that the key matches the certificate before accepting. If validation fails, the container exits with an error.
|
||||||
|
|
||||||
|
**Runtime certificate replacement** is available via the vendor UI at `/vendor/certificates`:
|
||||||
|
- Upload a new cert+key+CA bundle (staged, not yet active)
|
||||||
|
- Validate and activate (atomic swap, Traefik hot-reloads)
|
||||||
|
- Roll back to the previous certificate if needed
|
||||||
|
- Track which tenants need a restart to pick up CA bundle changes
|
||||||
|
|
||||||
|
### 4. Start the Stack
|
||||||
|
|
||||||
**Development** (ports exposed for direct access):
|
**Development** (ports exposed for direct access):
|
||||||
```bash
|
```bash
|
||||||
@@ -95,7 +113,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Verify Services
|
### 5. Verify Services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
# Health check
|
||||||
@@ -119,8 +137,8 @@ On first boot, Logto seeds its database automatically. Access the admin console
|
|||||||
- Assign the **Logto Management API** resource with all scopes
|
- Assign the **Logto Management API** resource with all scopes
|
||||||
4. Update `.env`:
|
4. Update `.env`:
|
||||||
```
|
```
|
||||||
LOGTO_M2M_CLIENT_ID=<app-id>
|
CAMELEER_SAAS_IDENTITY_M2MCLIENTID=<app-id>
|
||||||
LOGTO_M2M_CLIENT_SECRET=<app-secret>
|
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET=<app-secret>
|
||||||
```
|
```
|
||||||
5. Restart cameleer-saas: `docker compose restart cameleer-saas`
|
5. Restart cameleer-saas: `docker compose restart cameleer-saas`
|
||||||
|
|
||||||
@@ -204,7 +222,7 @@ To disable routing, set `exposedPort` to `null`.
|
|||||||
|
|
||||||
### View the Observability Dashboard
|
### View the Observability Dashboard
|
||||||
|
|
||||||
The cameleer3-server React SPA dashboard is available at:
|
The cameleer-server React SPA dashboard is available at:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost/dashboard
|
http://localhost/dashboard
|
||||||
@@ -215,7 +233,7 @@ This shows execution traces, route topology graphs, metrics, and logs for all de
|
|||||||
### Check Agent & Observability Status
|
### Check Agent & Observability Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Is the agent registered with cameleer3-server?
|
# Is the agent registered with cameleer-server?
|
||||||
curl "http://localhost:8080/api/apps/$APP_ID/agent-status" \
|
curl "http://localhost:8080/api/apps/$APP_ID/agent-status" \
|
||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
# Returns: registered, state (ACTIVE/STALE/DEAD/UNKNOWN), routeIds
|
# Returns: registered, state (ACTIVE/STALE/DEAD/UNKNOWN), routeIds
|
||||||
@@ -285,7 +303,47 @@ Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream`
|
|||||||
### Dashboard
|
### Dashboard
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `/dashboard` | cameleer3-server observability dashboard (forward-auth protected) |
|
| `/dashboard` | cameleer-server observability dashboard (forward-auth protected) |
|
||||||
|
|
||||||
|
### Vendor: Certificates (platform:admin)
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/vendor/certificates` | Overview (active, staged, archived, stale count) |
|
||||||
|
| POST | `/api/vendor/certificates/stage` | Upload cert+key+CA (multipart) |
|
||||||
|
| POST | `/api/vendor/certificates/activate` | Promote staged -> active |
|
||||||
|
| POST | `/api/vendor/certificates/restore` | Swap archived <-> active |
|
||||||
|
| DELETE | `/api/vendor/certificates/staged` | Discard staged cert |
|
||||||
|
| GET | `/api/vendor/certificates/stale-tenants` | Count tenants needing CA restart |
|
||||||
|
|
||||||
|
### Vendor: Tenants (platform:admin)
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/vendor/tenants` | List all tenants (includes fleet health: agentCount, environmentCount, agentLimit) |
|
||||||
|
| POST | `/api/vendor/tenants` | Create tenant (async provisioning) |
|
||||||
|
| GET | `/api/vendor/tenants/{id}` | Tenant detail + server state |
|
||||||
|
| POST | `/api/vendor/tenants/{id}/restart` | Restart server containers |
|
||||||
|
| POST | `/api/vendor/tenants/{id}/suspend` | Suspend tenant |
|
||||||
|
| POST | `/api/vendor/tenants/{id}/activate` | Activate tenant |
|
||||||
|
| DELETE | `/api/vendor/tenants/{id}` | Delete tenant |
|
||||||
|
| POST | `/api/vendor/tenants/{id}/license` | Renew license |
|
||||||
|
|
||||||
|
### Tenant Portal (org-scoped)
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/tenant/dashboard` | Tenant dashboard data |
|
||||||
|
| GET | `/api/tenant/license` | License details |
|
||||||
|
| POST | `/api/tenant/server/restart` | Restart server |
|
||||||
|
| GET | `/api/tenant/team` | List team members |
|
||||||
|
| POST | `/api/tenant/team/invite` | Invite team member |
|
||||||
|
| DELETE | `/api/tenant/team/{userId}` | Remove team member |
|
||||||
|
| GET | `/api/tenant/settings` | Tenant settings |
|
||||||
|
| GET | `/api/tenant/sso` | List SSO connectors |
|
||||||
|
| POST | `/api/tenant/sso` | Create SSO connector |
|
||||||
|
| GET | `/api/tenant/ca` | List tenant CA certificates |
|
||||||
|
| POST | `/api/tenant/ca` | Upload CA cert (staged) |
|
||||||
|
| POST | `/api/tenant/ca/{id}/activate` | Activate staged CA cert |
|
||||||
|
| DELETE | `/api/tenant/ca/{id}` | Remove CA cert |
|
||||||
|
| GET | `/api/tenant/audit` | Tenant audit log |
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -346,7 +404,7 @@ Output goes to `src/main/resources/static/` (configured in `vite.config.ts`). Th
|
|||||||
|
|
||||||
### SPA Routing
|
### SPA Routing
|
||||||
|
|
||||||
Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer3-server) is at `/dashboard`.
|
Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer-server) is at `/dashboard`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
BIN
audit-screenshots/01-dashboard.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/02-user-menu-open.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
audit-screenshots/03-search-dialog.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/04-error-filter.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/05-license-page.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
audit-screenshots/06-license-token-shown.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit-screenshots/07-admin-tenants-error.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
audit-screenshots/08-dark-mode.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/09-sidebar-collapsed.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/10-server-dashboard.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit-screenshots/11-server-dashboard-tab.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
audit-screenshots/12-server-runtime.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
audit-screenshots/13-server-deployments.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
audit-screenshots/14-server-audit-log.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-screenshots/15-server-environments.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
audit-screenshots/16-server-users-roles.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
audit-screenshots/17-server-oidc.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
audit-screenshots/17b-server-oidc-full.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
audit-screenshots/18-server-clickhouse.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-screenshots/19-server-database.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
audit-screenshots/20-server-api-docs.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
audit-screenshots/21-auto-refresh-enabled.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit-screenshots/22-login-page.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit-screenshots/23-sign-in-page.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
audit-screenshots/24-dashboard-fullpage.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit/01-platform-dashboard.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/02-user-menu-dropdown.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
audit/03-login-page.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
audit/04-login-error.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
audit/05-platform-dashboard-loggedin.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/06-license-page.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/07-license-token-revealed.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
audit/08-dashboard-dark-mode.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
audit/09-license-dark-mode.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
audit/10-server-dashboard.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
audit/11-search-modal.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/12-sidebar-collapsed.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/13-responsive-tablet.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
audit/14-responsive-mobile.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
audit/15-dashboard-desktop-1280.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
audit/16-license-features-detail.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
audit/17-license-limits-detail.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
audit/18-license-validity-detail.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
audit/19-tenant-info-detail.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
audit/20-kpi-strip-detail.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
audit/21-sidebar-detail.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
audit/22-header-bar-detail.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
269
audit/platform-ui-findings.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Cameleer SaaS Platform UI Audit Findings
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
**Auditor:** Claude Opus 4.6
|
||||||
|
**URL:** https://desktop-fb5vgj9.siegeln.internal/
|
||||||
|
**Credentials:** admin/admin
|
||||||
|
**Browser:** Playwright (Chromium)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Login Page (`/sign-in`)
|
||||||
|
|
||||||
|
**Screenshot:** `03-login-page.png`, `04-login-error.png`
|
||||||
|
|
||||||
|
### What works well
|
||||||
|
- Clean, centered card layout with consistent design system components
|
||||||
|
- Fun rotating subtitle taglines (e.g., "No ticket, no caravan") add personality
|
||||||
|
- Cameleer logo is displayed correctly
|
||||||
|
- Error handling works -- "Invalid username or password" alert appears on bad credentials (red alert banner)
|
||||||
|
- Sign in button is correctly disabled until both fields are populated
|
||||||
|
- Loading state on button during authentication
|
||||||
|
- Uses proper `autoComplete` attributes (`username`, `current-password`)
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Important | **No password visibility toggle** -- the Password input uses `type="password"` with no eye icon to reveal. Most modern login forms offer this. | Password field |
|
||||||
|
| Important | **Branding says "cameleer"** not "Cameleer" or "Cameleer SaaS" -- the product name on the login page is the internal repo name, not the user-facing brand | `.logo` text content |
|
||||||
|
| Nice-to-have | **No "Forgot password" link** -- even if it goes to a "contact admin" page, users expect this | Below password field |
|
||||||
|
| Nice-to-have | **No Enter-key submit hint** -- though Enter does work via form submit, there's no visual affordance | Form area |
|
||||||
|
| Nice-to-have | **Page title is "Sign in -- cameleer"** -- should match product branding ("Cameleer SaaS") | `<title>` tag |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Platform Dashboard (`/platform/`)
|
||||||
|
|
||||||
|
**Screenshots:** `05-platform-dashboard-loggedin.png`, `15-dashboard-desktop-1280.png`, `19-tenant-info-detail.png`, `20-kpi-strip-detail.png`
|
||||||
|
|
||||||
|
### What works well
|
||||||
|
- Clear tenant name as page heading ("Example Tenant")
|
||||||
|
- Tier badge next to tenant name provides immediate context
|
||||||
|
- KPI strip with Tier, Status, License cards is visually clean and well-structured
|
||||||
|
- License KPI card shows expiry date in green "expires 8.4.2027" trend indicator
|
||||||
|
- "Server Management" card provides clear description of what the server dashboard does
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Critical** | **Label/value collision in Tenant Information card** -- "Slugdefault", "Created8.4.2026" have no visual separation between label and value. The source uses `flex justify-between` but the deployed Card component doesn't give the inner `div` full width, so items stack/collapse. | Tenant Information card |
|
||||||
|
| **Critical** | **"Open Server Dashboard" appears 3 times** on one page: (1) primary button in header area below tenant name, (2) "Server Management" card with secondary button, (3) sidebar footer link. This is redundant and clutters the page. Reduce to 1-2 locations max. | Header area, Server Management card, sidebar footer |
|
||||||
|
| Important | **Breadcrumb is always empty** -- the `breadcrumb` prop is passed as `[]`. Platform pages should have breadcrumbs like "Platform > Dashboard" or "Platform > License". | TopBar breadcrumb nav |
|
||||||
|
| Important | **Massive empty space below content** -- the dashboard only has ~4 cards but the page extends far below with blank white/cream space. The page feels sparse and "stub-like." | Below Server Management card |
|
||||||
|
| Important | **Tier badge color is misleading** -- "LOW" tier uses `primary` (orange) color, which doesn't convey it's the lowest/cheapest tier. The `tierColor()` function in DashboardPage maps to enterprise=success, pro=primary, starter=warning, but the actual data uses LOW/MID/HIGH/BUSINESS tiers (defined in LicensePage). Dashboard and License pages have different tier color mappings. | Tier badge |
|
||||||
|
| Important | **Status is shown redundantly** -- "ACTIVE" appears in (1) KPI strip Status card, (2) Tenant Information card with badge, and (3) header area badge. This is excessive for a single piece of information. | Multiple locations |
|
||||||
|
| Nice-to-have | **No tenant ID/slug in breadcrumb or subtitle** -- the slug "default" only appears buried in the Tenant Information card | Page header area |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. License Page (`/platform/license`)
|
||||||
|
|
||||||
|
**Screenshots:** `06-license-page.png`, `07-license-token-revealed.png`, `16-license-features-detail.png`, `17-license-limits-detail.png`, `18-license-validity-detail.png`
|
||||||
|
|
||||||
|
### What works well
|
||||||
|
- Well-structured layout with logical sections (Validity, Features, Limits, License Token)
|
||||||
|
- Tier badge in header provides context
|
||||||
|
- Feature matrix clearly shows enabled vs disabled features
|
||||||
|
- "Days remaining" with color-coded badge (green for healthy, warning for <30 days, red for expired)
|
||||||
|
- Token show/hide toggle works correctly
|
||||||
|
- Token revealed in monospace code block with appropriate styling
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Critical** | **Label/value collision in Validity section** -- "Issued8. April 2026" and "Expires8. April 2027" have no separation. Source code uses `flex items-center justify-between` but the flex container seems to not be stretching to full width. | Validity card rows |
|
||||||
|
| **Critical** | **Label/value collision in Limits section** -- "Max Agents3", "Retention Days7", "Max Environments1" have labels and values mashed together. Source uses `flex items-center justify-between` layout but the same rendering bug prevents proper spacing. | Limits card rows |
|
||||||
|
| Important | **No "Copy to clipboard" button** for the license token -- users need to manually select and copy. A copy button with confirmation toast is standard UX for tokens/secrets. | License Token section |
|
||||||
|
| Important | **Feature badge text mismatch** -- Source code says `'Not included'` for disabled features, but deployed version shows "DISABLED". This suggests the deployed build is out of sync with the source. | Features card badges |
|
||||||
|
| Important | **"Disabled" badge color** -- disabled features use `color='auto'` (which renders as a neutral/red-ish badge), while "Enabled" uses green. Consider using a muted gray for "Not included" to make it feel less like an error state. Red implies something is wrong, but a feature simply not being in the plan is not an error. | Features card disabled badges |
|
||||||
|
| Nice-to-have | **Limits values are not right-aligned** -- due to the label/value collision, the numeric values don't align in a column, making comparison harder | Limits card |
|
||||||
|
| Nice-to-have | **No units on limits** -- "Retention Days7" should be "7 days", "Max Agents3" should be "3 agents" or just "3" with clear formatting | Limits card values |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin Pages (`/platform/admin/tenants`)
|
||||||
|
|
||||||
|
**No screenshot available -- page returns HTTP error**
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Critical** | **Admin page returns HTTP error (net::ERR_HTTP_RESPONSE_CODE_FAILURE)** -- navigating to `/platform/admin/tenants` fails with an HTTP error. The route exists in the router (`AdminTenantsPage`), but the admin section is not visible in the sidebar (no "Platform" item shown). | Admin route |
|
||||||
|
| Important | **Admin section not visible in sidebar** -- the `platform:admin` scope check in Layout.tsx hides the "Platform" sidebar item. Even though the user is "admin", they apparently don't have the `platform:admin` scope in their JWT. This may be intentional (scope not assigned) or a bug. | Sidebar Platform section |
|
||||||
|
| Important | **No graceful fallback for unauthorized admin access** -- if a user manually navigates to `/admin/tenants` without the scope, the page should show a "Not authorized" message rather than an HTTP error. | Admin route error handling |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Navigation
|
||||||
|
|
||||||
|
**Screenshots:** `21-sidebar-detail.png`, `12-sidebar-collapsed.png`
|
||||||
|
|
||||||
|
### What works well
|
||||||
|
- Clean sidebar with Cameleer SaaS branding and logo
|
||||||
|
- "Open Server Dashboard" in sidebar footer is a good location
|
||||||
|
- Sidebar has only 2 navigation items (Dashboard, License) which keeps it simple
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Critical** | **No active state on sidebar navigation items** -- when on the Dashboard page, neither Dashboard nor License is highlighted/active. The sidebar uses `Sidebar.Section` components with `open={false}` as navigation links via `onToggle`, but `Section` is designed for expandable/collapsible groups, not navigation links. There is no visual indicator of the current page. | Sidebar items |
|
||||||
|
| Important | **Sidebar collapse doesn't work visually** -- clicking "Collapse sidebar" toggles the `active` state on the button but the sidebar doesn't visually collapse. The Layout component passes `collapsed={false}` as a hardcoded prop and `onCollapseToggle={() => {}}` as a no-op. | Sidebar collapse button |
|
||||||
|
| Important | **No clear distinction between "platform" and "server" levels** -- there's nothing in the sidebar header that says "Platform" vs "Server". The sidebar says "Cameleer SaaS" but when you switch to the server dashboard, it becomes a completely different app. A user might not understand the relationship. | Sidebar header |
|
||||||
|
| Nice-to-have | **"Open Server Dashboard" opens in new tab** -- `window.open('/server/', '_blank', 'noopener')` is used. While reasonable, there's no visual indicator (external link icon) that it will open a new tab. | Sidebar footer link, dashboard buttons |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Header Bar (TopBar)
|
||||||
|
|
||||||
|
**Screenshot:** `22-header-bar-detail.png`
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Critical** | **Server-specific controls shown on platform pages** -- the TopBar always renders: (1) Search (Ctrl+K), (2) Status filters (OK/Warn/Error/Running), (3) Time range pills (1h/3h/6h/Today/24h/7d), (4) Auto-refresh toggle (MANUAL/AUTO). None of these are relevant to the platform dashboard or license page. They are observability controls designed for the server's exchange/route monitoring. | Entire TopBar filter area |
|
||||||
|
| Important | **Search button does nothing** -- clicking "Search..." on the platform does not open a search modal. The CommandPaletteProvider is likely not configured for the platform context. | Search button |
|
||||||
|
| Important | **Status filter buttons are interactive but meaningless** -- clicking OK/Warn/Error/Running on platform pages toggles state (global filter provider) but has no effect on the displayed content. | Status filter buttons |
|
||||||
|
| Important | **Time range selector is interactive but meaningless** -- similarly, changing the time range from 1h to 7d has no effect on platform pages. | Time range pills |
|
||||||
|
| Important | **Auto-refresh toggle is misleading** -- shows "MANUAL" toggle on platform pages where there's nothing to auto-refresh. | Auto-refresh button |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. User Menu
|
||||||
|
|
||||||
|
**Screenshot:** `02-user-menu-dropdown.png`
|
||||||
|
|
||||||
|
### What works well
|
||||||
|
- User name "admin" and avatar initials "AD" displayed correctly
|
||||||
|
- Dropdown appears on click with Logout option
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Important | **User menu only has "Logout"** -- there's no "Profile", "Settings", "About", or "Switch Tenant" option. For a SaaS platform, users should at minimum see their role and tenant context. | User dropdown menu |
|
||||||
|
| Nice-to-have | **Avatar shows "AD" for "admin"** -- the Avatar component appears to use first 2 characters of the name. For "admin" this produces "AD" which looks like initials for a different name. | Avatar component |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Dark Mode
|
||||||
|
|
||||||
|
**Screenshots:** `08-dashboard-dark-mode.png`, `09-license-dark-mode.png`
|
||||||
|
|
||||||
|
### What works well
|
||||||
|
- Dark mode toggle works and applies globally
|
||||||
|
- Background transitions to dark brown/charcoal
|
||||||
|
- Text colors adapt appropriately
|
||||||
|
- Cards maintain visual distinction from background
|
||||||
|
- Design system tokens handle the switch smoothly
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Nice-to-have | **Dark mode is warm-toned (brown)** rather than the more common cool dark gray/charcoal. This is consistent with the design system's cameleer branding but may feel unusual to users accustomed to dark mode in other apps. | Global dark theme |
|
||||||
|
| Nice-to-have | **The same label/value collision issues appear in dark mode** -- these are layout bugs, not color bugs, so dark mode doesn't help or hurt. | Card content |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Responsiveness
|
||||||
|
|
||||||
|
**Screenshots:** `13-responsive-tablet.png`, `14-responsive-mobile.png`
|
||||||
|
|
||||||
|
### Issues found
|
||||||
|
|
||||||
|
| Severity | Issue | Element |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **Critical** | **Mobile layout is broken** -- at 375px width, the sidebar overlaps the main content. The KPI strip cards are truncated ("LO...", "AC..."). The header bar overflows. Content is unreadable. | Full page at mobile widths |
|
||||||
|
| Important | **Tablet layout (768px) is functional but crowded** -- sidebar takes significant width, header bar items are compressed ("Se..." for Search), but content is readable. KPI strip wraps correctly. | Full page at tablet widths |
|
||||||
|
| Important | **Sidebar doesn't collapse on mobile** -- there's no hamburger menu or responsive sidebar behavior. The sidebar is always visible, eating screen space on narrow viewports. | Sidebar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-cutting Concerns
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
- Dashboard and License pages both show a centered `Spinner` during loading -- this works well.
|
||||||
|
- `EmptyState` component used for "No tenant associated" and "License unavailable" -- good error handling in components.
|
||||||
|
|
||||||
|
### Error States
|
||||||
|
- Login page error handling is good (alert banner)
|
||||||
|
- No visible error boundary for unexpected errors on platform pages
|
||||||
|
- Admin route fails silently with HTTP error -- no user-facing error message
|
||||||
|
|
||||||
|
### Toast Notifications
|
||||||
|
- No toast notifications observed during the audit
|
||||||
|
- License token copy should trigger a toast confirmation (if a copy button existed)
|
||||||
|
|
||||||
|
### Confirmation Dialogs
|
||||||
|
- No destructive actions available on the platform (no delete/deactivate buttons) so no confirmation dialogs needed currently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Issues by Severity
|
||||||
|
|
||||||
|
### Critical (5)
|
||||||
|
1. **Label/value collision** throughout Tenant Information card, License Validity, and License Limits sections -- labels and values run together without spacing
|
||||||
|
2. **"Open Server Dashboard" appears 3 times** on the dashboard page -- excessive redundancy
|
||||||
|
3. **No active state on sidebar navigation items** -- users can't tell which page they're on
|
||||||
|
4. **Server-specific header controls shown on platform pages** -- search, status filters, time range, auto-refresh are all meaningless on platform pages
|
||||||
|
5. **Mobile layout completely broken** -- sidebar overlaps content, content truncated
|
||||||
|
|
||||||
|
### Important (17)
|
||||||
|
1. No password visibility toggle on login
|
||||||
|
2. Branding says "cameleer" instead of product name on login
|
||||||
|
3. Breadcrumbs always empty on platform pages
|
||||||
|
4. Massive empty space below dashboard content
|
||||||
|
5. Tier badge color mapping inconsistent between Dashboard and License pages
|
||||||
|
6. Status shown redundantly in 3 places on dashboard
|
||||||
|
7. No clipboard copy button for license token
|
||||||
|
8. Feature badge text mismatch between source and deployed build
|
||||||
|
9. "Disabled" badge uses red-ish color (implies error, not "not in plan")
|
||||||
|
10. Admin page returns HTTP error with no graceful fallback
|
||||||
|
11. Admin section invisible in sidebar despite being admin user
|
||||||
|
12. Sidebar collapse button doesn't work (no-op handler)
|
||||||
|
13. No clear platform vs server level distinction
|
||||||
|
14. Search button does nothing on platform
|
||||||
|
15. Status filters and time range interactive but meaningless on platform
|
||||||
|
16. User menu only has Logout (no profile/settings)
|
||||||
|
17. Sidebar doesn't collapse/hide on mobile
|
||||||
|
|
||||||
|
### Nice-to-have (8)
|
||||||
|
1. No "Forgot password" link on login
|
||||||
|
2. Login page title uses "cameleer" branding
|
||||||
|
3. No external link icon on "Open Server Dashboard"
|
||||||
|
4. Avatar shows "AD" for "admin"
|
||||||
|
5. No units on limit values
|
||||||
|
6. Dark mode warm-toned (not standard cool dark)
|
||||||
|
7. No Enter-key submit hint
|
||||||
|
8. No tenant ID in breadcrumb/subtitle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overarching Assessment
|
||||||
|
|
||||||
|
The platform UI currently feels like a **thin shell** around the server dashboard. It has only 2 functioning pages (Dashboard and License), and both suffer from the same fundamental layout bug (label/value collision in Card components). The header bar is entirely borrowed from the server observability UI without any platform-specific adaptation, making 70% of the header controls irrelevant.
|
||||||
|
|
||||||
|
**Key architectural concerns:**
|
||||||
|
1. The TopBar component from the design system is monolithic -- it always renders server-specific controls (status filters, time range, search). The platform needs either a simplified TopBar variant or the ability to hide these sections.
|
||||||
|
2. The sidebar uses `Sidebar.Section` (expandable groups) as navigation links, which prevents active-state highlighting. It should use `Sidebar.Link` or a similar component.
|
||||||
|
3. The platform provides very little actionable functionality -- a user can view their tenant info and license, but can't manage anything. The "Server Management" card is just a link to another app.
|
||||||
|
|
||||||
|
**What works well overall:**
|
||||||
|
- Design system integration is solid (same look and feel as server)
|
||||||
|
- Dark mode works correctly
|
||||||
|
- Loading and error states are handled
|
||||||
|
- Login page is clean and functional
|
||||||
|
- KPI strip component is effective at summarizing key info
|
||||||
|
|
||||||
|
**Recommended priorities:**
|
||||||
|
1. Fix the label/value collision bug (affects 3 cards across 2 pages)
|
||||||
|
2. Hide or replace server-specific header controls on platform pages
|
||||||
|
3. Add sidebar active state and fix the collapse behavior
|
||||||
|
4. Add clipboard copy for license token
|
||||||
|
5. Fix mobile responsiveness
|
||||||
433
audit/source-code-findings.md
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
# Cameleer SaaS UI — Source Code Audit Findings
|
||||||
|
|
||||||
|
**Audit date:** 2026-04-09
|
||||||
|
**Scope:** `ui/src/` (platform SPA) + `ui/sign-in/src/` (custom Logto sign-in)
|
||||||
|
**Design system:** `@cameleer/design-system@0.1.38`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Layout and Styling Patterns
|
||||||
|
|
||||||
|
### 1.1 Container Padding/Margin
|
||||||
|
|
||||||
|
All three page components use an identical outer wrapper pattern:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DashboardPage.tsx:67, LicensePage.tsx:82, AdminTenantsPage.tsx:60
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verdict:** Consistent across all pages. However, this padding is applied by each page individually rather than by the `Layout` component. If a new page omits `p-6`, the layout will be inconsistent. Consider moving container padding to the `Layout` component wrapping `<Outlet />`.
|
||||||
|
|
||||||
|
### 1.2 Use of Design System Components vs Custom HTML
|
||||||
|
|
||||||
|
| Component | DashboardPage | LicensePage | AdminTenantsPage |
|
||||||
|
|-----------|:---:|:---:|:---:|
|
||||||
|
| Badge | Yes | Yes | Yes |
|
||||||
|
| Button | Yes | - | - |
|
||||||
|
| Card | Yes | Yes | Yes |
|
||||||
|
| DataTable | - | - | Yes |
|
||||||
|
| EmptyState | Yes | Yes | - |
|
||||||
|
| KpiStrip | Yes | - | - |
|
||||||
|
| Spinner | Yes | Yes | Yes |
|
||||||
|
|
||||||
|
**Issues found:**
|
||||||
|
|
||||||
|
- **LicensePage.tsx:166-170** — Raw `<button>` for "Show token" / "Hide token" toggle instead of DS `Button variant="ghost"`:
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
|
||||||
|
onClick={() => setTokenExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
This uses hardcoded Tailwind color classes (`text-primary-400`, `hover:text-primary-300`) instead of design tokens or a DS Button.
|
||||||
|
|
||||||
|
- **LicensePage.tsx:174** — Raw `<div>` + `<code>` for token display instead of DS `CodeBlock` (which is available and supports `copyable`):
|
||||||
|
```tsx
|
||||||
|
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto">
|
||||||
|
<code className="text-xs font-mono text-white/80 break-all">
|
||||||
|
{license.token}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **AdminTenantsPage.tsx** — No empty state when `tenants` is empty. The DataTable renders with zero rows but no guidance for the admin.
|
||||||
|
|
||||||
|
### 1.3 Card/Section Grouping
|
||||||
|
|
||||||
|
- **DashboardPage** uses: KpiStrip + "Tenant Information" Card + "Server Management" Card. Good grouping.
|
||||||
|
- **LicensePage** uses: "Validity" Card + "Features" Card + "Limits" Card + "License Token" Card. Well-structured.
|
||||||
|
- **AdminTenantsPage** uses: single Card wrapping DataTable. Appropriate for a list view.
|
||||||
|
|
||||||
|
### 1.4 Typography
|
||||||
|
|
||||||
|
All pages use the same heading pattern:
|
||||||
|
```tsx
|
||||||
|
<h1 className="text-2xl font-semibold text-white">...</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** `text-white` is hardcoded rather than using a DS color token like `var(--text-primary)`. This will break if the design system ever supports a light theme (the DS has `ThemeProvider` and a theme toggle in the TopBar). The same pattern appears:
|
||||||
|
- `DashboardPage.tsx:73` — `text-white`
|
||||||
|
- `LicensePage.tsx:85` — `text-white`
|
||||||
|
- `AdminTenantsPage.tsx:62` — `text-white`
|
||||||
|
|
||||||
|
Similarly, muted text uses `text-white/60` and `text-white/80` throughout:
|
||||||
|
- `DashboardPage.tsx:96` — `text-white/80`
|
||||||
|
- `LicensePage.tsx:96,106,109` — `text-white/60`, `text-white`
|
||||||
|
- `LicensePage.tsx:129` — `text-sm text-white`
|
||||||
|
- `LicensePage.tsx:150` — `text-sm text-white/60`
|
||||||
|
|
||||||
|
These should use `var(--text-primary)` / `var(--text-secondary)` / `var(--text-muted)` from the design system.
|
||||||
|
|
||||||
|
### 1.5 Color Token Usage
|
||||||
|
|
||||||
|
**Positive:** The sign-in page CSS module (`SignInPage.module.css`) correctly uses DS variables:
|
||||||
|
```css
|
||||||
|
color: var(--text-primary); /* line 30 */
|
||||||
|
color: var(--text-muted); /* line 40 */
|
||||||
|
background: var(--bg-base); /* line 7 */
|
||||||
|
font-family: var(--font-body); /* line 20 */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Negative:** The platform SPA pages bypass the design system's CSS variables entirely, using Tailwind utility classes with hardcoded dark-theme colors (`text-white`, `text-white/60`, `bg-white/5`, `border-white/10`, `divide-white/10`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Interaction Patterns
|
||||||
|
|
||||||
|
### 2.1 Button Placement and Order
|
||||||
|
|
||||||
|
- **DashboardPage.tsx:81-87** — "Open Server Dashboard" button is top-right (standard). Also repeated inside a Card at line 119-125. Two identical CTAs on the same page is redundant.
|
||||||
|
- No forms exist in the platform pages. No create/edit/delete operations are exposed in the UI (read-only dashboard).
|
||||||
|
|
||||||
|
### 2.2 Confirmation Dialogs for Destructive Actions
|
||||||
|
|
||||||
|
- The DS provides `ConfirmDialog` and `AlertDialog` — neither is used anywhere.
|
||||||
|
- **AdminTenantsPage.tsx:47-57** — Row click silently switches tenant context and navigates to `/`. No confirmation dialog for context switching, which could be disorienting. The user clicks a row in the admin table, and their entire session context changes.
|
||||||
|
|
||||||
|
### 2.3 Loading States
|
||||||
|
|
||||||
|
All pages use the same loading pattern — centered `<Spinner />` in a fixed-height container:
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Full-page auth loading screens (LoginPage, CallbackPage, ProtectedRoute, OrgResolver) use inline styles instead of Tailwind:
|
||||||
|
```tsx
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
```
|
||||||
|
This is inconsistent with the page components which use Tailwind classes.
|
||||||
|
|
||||||
|
- The `main.tsx` app bootstrap loading (line 59) also uses inline styles. Six files use this identical inline style pattern — it should be a shared component or consistent class.
|
||||||
|
|
||||||
|
- No `Skeleton` components are used anywhere, despite the DS providing `Skeleton`. For the dashboard and license pages which fetch data, skeletons would give better perceived performance than a generic spinner.
|
||||||
|
|
||||||
|
### 2.4 Error Handling
|
||||||
|
|
||||||
|
- **API client (`api/client.ts`):** Errors are thrown as generic `Error` objects. No toast notifications on failure.
|
||||||
|
- **LicensePage.tsx:63-69** — Shows `EmptyState` for `isError`. Good.
|
||||||
|
- **DashboardPage.tsx** — No error state handling at all. If `useTenant()` or `useLicense()` fails, the page renders with fallback `-` values silently. No `isError` check.
|
||||||
|
- **AdminTenantsPage.tsx** — No error state. If `useAllTenants()` fails, falls through to rendering the table with empty data.
|
||||||
|
- **OrgResolver.tsx:88-89** — On error, renders `null` (blank screen). The user sees nothing — no error message, no retry option, no redirect. This is the worst error UX in the app.
|
||||||
|
- No component imports or uses `useToast()` from the DS. Toasts are never shown for any operation.
|
||||||
|
|
||||||
|
### 2.5 Empty States
|
||||||
|
|
||||||
|
- **DashboardPage.tsx:57-63** — `EmptyState` for no tenant. Good.
|
||||||
|
- **LicensePage.tsx:54-60** — `EmptyState` for no tenant. Good.
|
||||||
|
- **LicensePage.tsx:63-69** — `EmptyState` for license fetch error. Good.
|
||||||
|
- **AdminTenantsPage.tsx** — **Missing.** No empty state when `tenants` array is empty. DataTable will render an empty table body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Component Usage
|
||||||
|
|
||||||
|
### 3.1 DS Imports by File
|
||||||
|
|
||||||
|
| File | DS Components Imported |
|
||||||
|
|------|----------------------|
|
||||||
|
| `main.tsx` | ThemeProvider, ToastProvider, BreadcrumbProvider, GlobalFilterProvider, CommandPaletteProvider, Spinner |
|
||||||
|
| `Layout.tsx` | AppShell, Sidebar, TopBar |
|
||||||
|
| `DashboardPage.tsx` | Badge, Button, Card, EmptyState, KpiStrip, Spinner |
|
||||||
|
| `LicensePage.tsx` | Badge, Card, EmptyState, Spinner |
|
||||||
|
| `AdminTenantsPage.tsx` | Badge, Card, DataTable, Spinner + Column type |
|
||||||
|
| `LoginPage.tsx` | Spinner |
|
||||||
|
| `CallbackPage.tsx` | Spinner |
|
||||||
|
| `ProtectedRoute.tsx` | Spinner |
|
||||||
|
| `OrgResolver.tsx` | Spinner |
|
||||||
|
| `SignInPage.tsx` (sign-in) | Card, Input, Button, Alert, FormField |
|
||||||
|
|
||||||
|
### 3.2 Available but Unused DS Components
|
||||||
|
|
||||||
|
These DS components are relevant to the platform UI but unused:
|
||||||
|
|
||||||
|
| Component | Could be used for |
|
||||||
|
|-----------|------------------|
|
||||||
|
| `AlertDialog` / `ConfirmDialog` | Confirming tenant context switch in AdminTenantsPage |
|
||||||
|
| `CodeBlock` | License token display (currently raw HTML) |
|
||||||
|
| `Skeleton` | Loading states instead of spinner |
|
||||||
|
| `Tooltip` | Badge hover explanations, info about features |
|
||||||
|
| `StatusDot` | Tenant status indicators |
|
||||||
|
| `Breadcrumb` / `useBreadcrumb` | Page navigation context (currently empty `[]`) |
|
||||||
|
| `LoginForm` | Could replace the custom sign-in form (DS already has one) |
|
||||||
|
| `useToast` | Error/success notifications |
|
||||||
|
|
||||||
|
### 3.3 Raw HTML Where DS Components Exist
|
||||||
|
|
||||||
|
1. **LicensePage.tsx:166-170** — Raw `<button>` instead of `Button variant="ghost"`
|
||||||
|
2. **LicensePage.tsx:174-178** — Raw `<div><code>` instead of `CodeBlock`
|
||||||
|
3. **Layout.tsx:26-62** — Four inline SVG icon components instead of using `lucide-react` icons (the DS depends on lucide-react)
|
||||||
|
4. **DashboardPage.tsx:95-112** — Manual label/value list with `<div className="flex justify-between">` instead of using a DS pattern (the DS has no explicit key-value list component, so this is acceptable)
|
||||||
|
|
||||||
|
### 3.4 Styling Approach
|
||||||
|
|
||||||
|
- **Platform SPA pages:** Tailwind CSS utility classes (via class names like `space-y-6`, `p-6`, `flex`, `items-center`, etc.)
|
||||||
|
- **Sign-in page:** CSS modules (`SignInPage.module.css`) with DS CSS variables
|
||||||
|
- **Auth loading screens:** Inline `style={{}}` objects
|
||||||
|
- **No CSS modules** in the platform SPA at all (zero `.module.css` files in `ui/src/`)
|
||||||
|
|
||||||
|
This is a three-way inconsistency: Tailwind in pages, CSS modules in sign-in, inline styles in auth components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Navigation
|
||||||
|
|
||||||
|
### 4.1 Sidebar
|
||||||
|
|
||||||
|
**File:** `ui/src/components/Layout.tsx:70-118`
|
||||||
|
|
||||||
|
The sidebar uses `Sidebar.Section` with `open={false}` and `{null}` children as a workaround to make sections act as navigation links (via `onToggle`). This is a semantic misuse — sections are designed as collapsible containers, not nav links.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<DashboardIcon />}
|
||||||
|
label="Dashboard"
|
||||||
|
open={false}
|
||||||
|
onToggle={() => navigate('/')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- No `active` state is set on any section. The DS supports `active?: boolean` on `SidebarSectionProps` (line 988 of DS types), but it's never passed. The user has no visual indicator of which page they're on.
|
||||||
|
- `collapsed={false}` is hardcoded with `onCollapseToggle={() => {}}` — the sidebar cannot be collapsed. This is a no-op handler.
|
||||||
|
- Only three nav items: Dashboard, License, Platform (admin-only). Very sparse.
|
||||||
|
|
||||||
|
### 4.2 "Open Server Dashboard"
|
||||||
|
|
||||||
|
Two implementations, both identical:
|
||||||
|
1. **Sidebar footer** (`Layout.tsx:112-116`): `Sidebar.FooterLink` with `window.open('/server/', '_blank', 'noopener')`
|
||||||
|
2. **Dashboard page** (`DashboardPage.tsx:84`): Primary Button, same `window.open` call
|
||||||
|
3. **Dashboard page** (`DashboardPage.tsx:120-125`): Secondary Button in a Card, same `window.open` call
|
||||||
|
|
||||||
|
Three separate "Open Server Dashboard" triggers on the dashboard. The footer link is good; the two dashboard buttons are redundant.
|
||||||
|
|
||||||
|
### 4.3 Breadcrumbs
|
||||||
|
|
||||||
|
**File:** `Layout.tsx:124` — `<TopBar breadcrumb={[]} ... />`
|
||||||
|
|
||||||
|
Breadcrumbs are permanently empty. The DS provides `useBreadcrumb()` hook (exported, see line 1255 of DS types) that pages can call to set page-specific breadcrumbs, but none of the pages use it. The TopBar renders an empty breadcrumb area.
|
||||||
|
|
||||||
|
### 4.4 User Menu / Avatar
|
||||||
|
|
||||||
|
**File:** `Layout.tsx:125-126`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<TopBar
|
||||||
|
user={username ? { name: username } : undefined}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
The TopBar's `user` prop triggers a `Dropdown` with only a "Logout" option. The avatar is rendered by the DS using the `Avatar` component with the user's name.
|
||||||
|
|
||||||
|
**Issue:** When `username` is `null` (common if the Logto ID token doesn't have `username`, `name`, or `email` claims), no user indicator is shown at all — no avatar, no logout button. The user has no way to log out from the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Header Bar
|
||||||
|
|
||||||
|
### 5.1 Shared TopBar with Server
|
||||||
|
|
||||||
|
The platform SPA and the server SPA both use the same `TopBar` component from `@cameleer/design-system`. This means they share identical header chrome.
|
||||||
|
|
||||||
|
### 5.2 Irrelevant Controls on Platform Pages
|
||||||
|
|
||||||
|
**Critical issue.** The `TopBar` component (DS source, lines 5569-5588 of `index.es.js`) **always** renders:
|
||||||
|
|
||||||
|
1. **Status filter pills** (Completed, Warning, Error, Running) — `ButtonGroup` with global filter status values
|
||||||
|
2. **Time range dropdown** — `TimeRangeDropdown` with presets like "Last 1h", "Last 24h"
|
||||||
|
3. **Auto-refresh toggle** — "AUTO" / "MANUAL" button
|
||||||
|
4. **Theme toggle** — Light/dark mode switch
|
||||||
|
5. **Command palette search** — "Search... Ctrl+K" button
|
||||||
|
|
||||||
|
These controls are hardcoded in the DS `TopBar` component. They read from `useGlobalFilters()` and operate on exchange status filters and time ranges — concepts that are **completely irrelevant** to the SaaS platform pages (Dashboard, License, Admin Tenants).
|
||||||
|
|
||||||
|
The platform wraps everything in `GlobalFilterProvider` (in `main.tsx:96`), which initializes the filter state, but nothing in the platform UI reads or uses these filters. They are dead UI elements that confuse users.
|
||||||
|
|
||||||
|
**Recommendation:** Either:
|
||||||
|
- The DS should make these controls optional/configurable on `TopBar`
|
||||||
|
- The platform should use a simpler header component
|
||||||
|
- The platform should not wrap in `GlobalFilterProvider` / `CommandPaletteProvider` (but this may cause runtime errors if TopBar assumes they exist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Specific Issues
|
||||||
|
|
||||||
|
### 6.1 Label/Value Formatting — "Slugdefault" Concatenation Bug
|
||||||
|
|
||||||
|
**Not found in source code.** The source code properly formats label/value pairs with `flex justify-between` layout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DashboardPage.tsx:96-99
|
||||||
|
<div className="flex justify-between text-white/80">
|
||||||
|
<span>Slug</span>
|
||||||
|
<span className="font-mono">{tenant?.slug ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
If "Slugdefault" concatenation is visible in the UI, it's a **rendering/CSS issue** rather than a template bug — the `flex justify-between` may collapse if the container is too narrow, or there may be a DS Card padding issue causing the spans to not separate. The code itself has proper separation.
|
||||||
|
|
||||||
|
Similarly for limits on the License page:
|
||||||
|
```tsx
|
||||||
|
// LicensePage.tsx:147-155
|
||||||
|
<span className="text-sm text-white/60">{label}</span>
|
||||||
|
<span className="text-sm font-mono text-white">{value !== undefined ? value : '—'}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Labels and values are in separate `<span>` elements within `flex justify-between` containers. The code is correct.
|
||||||
|
|
||||||
|
### 6.2 Badge Colors
|
||||||
|
|
||||||
|
**Feature badges (LicensePage.tsx:130-133):**
|
||||||
|
```tsx
|
||||||
|
<Badge
|
||||||
|
label={enabled ? 'Enabled' : 'Not included'}
|
||||||
|
color={enabled ? 'success' : 'auto'}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Enabled features: `color="success"` (green) — appropriate
|
||||||
|
- Disabled features: `color="auto"` — this uses the DS's auto-color logic (hash-based). For a disabled/not-included state, `color="error"` or a neutral muted variant would be more appropriate to clearly communicate "not available."
|
||||||
|
|
||||||
|
**Tenant status badges (DashboardPage.tsx:102-105, AdminTenantsPage.tsx:24-29):**
|
||||||
|
```tsx
|
||||||
|
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||||
|
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||||
|
```
|
||||||
|
|
||||||
|
- ACTIVE: green — appropriate
|
||||||
|
- Anything else (SUSPENDED, PENDING): yellow/warning — reasonable but SUSPENDED should arguably be `error` (red)
|
||||||
|
|
||||||
|
**Tier badges:** Use `tierColor()` function but it's defined differently in each file:
|
||||||
|
|
||||||
|
- `DashboardPage.tsx:12-18` maps: enterprise->success, pro->primary, starter->warning
|
||||||
|
- `LicensePage.tsx:25-33` maps: BUSINESS->success, HIGH->primary, MID->warning, LOW->error
|
||||||
|
|
||||||
|
These use **different tier names** (enterprise/pro/starter vs BUSINESS/HIGH/MID/LOW). One is for tenant tiers, the other for license tiers, but the inconsistency suggests either the data model has diverged or one mapping is stale.
|
||||||
|
|
||||||
|
### 6.3 Sign-In Page (`ui/sign-in/src/`)
|
||||||
|
|
||||||
|
**Positive findings:**
|
||||||
|
- Uses DS components: `Card`, `Input`, `Button`, `Alert`, `FormField`
|
||||||
|
- Uses CSS modules with DS CSS variables (`var(--bg-base)`, `var(--text-primary)`, etc.)
|
||||||
|
- Proper form with `aria-label="Sign in"`, `autoComplete` attributes
|
||||||
|
- Loading state on submit button via `loading` prop
|
||||||
|
- Error display via DS `Alert variant="error"`
|
||||||
|
- Creative rotating subtitle strings — good personality touch
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
1. **No `ThemeProvider` wrapper** (`sign-in/src/main.tsx`):
|
||||||
|
```tsx
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
The sign-in page imports `@cameleer/design-system/style.css` which provides CSS variable defaults, so it works. But the theme toggle won't function, and if the DS ever requires `ThemeProvider` for initialization, this will break.
|
||||||
|
|
||||||
|
2. **No `ToastProvider`** — if any DS component internally uses `useToast()`, it will throw.
|
||||||
|
|
||||||
|
3. **Hardcoded branding** (`SignInPage.tsx:61`):
|
||||||
|
```tsx
|
||||||
|
cameleer
|
||||||
|
```
|
||||||
|
The brand name is hardcoded text, not sourced from configuration.
|
||||||
|
|
||||||
|
4. **`React` import unused** (`SignInPage.tsx:1`): `useMemo` and `useState` are imported from `react` but the `import React` default import is absent, which is fine for React 19.
|
||||||
|
|
||||||
|
5. **No "forgot password" flow** — the form has username + password only. No recovery link. The DS `LoginForm` component supports `onForgotPassword` and `onSignUp` callbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Architecture Observations
|
||||||
|
|
||||||
|
### 7.1 Provider Stack Over-provisioning
|
||||||
|
|
||||||
|
`main.tsx` wraps the app in:
|
||||||
|
```
|
||||||
|
ThemeProvider > ToastProvider > BreadcrumbProvider > GlobalFilterProvider > CommandPaletteProvider
|
||||||
|
```
|
||||||
|
|
||||||
|
`GlobalFilterProvider` and `CommandPaletteProvider` are server-dashboard concepts (exchange status filters, time range, search). They are unused by any platform page but are required because `TopBar` reads from them internally. This creates coupling between the server's observability UI concerns and the SaaS platform pages.
|
||||||
|
|
||||||
|
### 7.2 Route Guard Nesting
|
||||||
|
|
||||||
|
The route structure is:
|
||||||
|
```
|
||||||
|
ProtectedRoute > OrgResolver > Layout > (pages)
|
||||||
|
```
|
||||||
|
|
||||||
|
`OrgResolver` fetches `/api/me` and resolves tenant context. If it fails (`isError`), it renders `null` — a blank screen inside the Layout shell. This means the sidebar and TopBar render but the content area is completely empty with no explanation.
|
||||||
|
|
||||||
|
### 7.3 Unused Import
|
||||||
|
|
||||||
|
- `LicensePage.tsx:1` imports `React` and `useState` — `React` import is not needed with React 19's JSX transform, and `useState` is used so that's fine. But `React` as a namespace import isn't used.
|
||||||
|
|
||||||
|
### 7.4 DataTable Requires `id` Field
|
||||||
|
|
||||||
|
`AdminTenantsPage.tsx:67` passes `tenants` to `DataTable`. The DS type requires `T extends { id: string }`. The `TenantResponse` type has `id: string`, so this works, but the `createdAt` column (line 31) renders the raw ISO timestamp string without formatting — unlike DashboardPage which formats it with `toLocaleDateString()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Summary of Issues by Severity
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
| # | Issue | File(s) | Line(s) |
|
||||||
|
|---|-------|---------|---------|
|
||||||
|
| H1 | TopBar shows irrelevant status filters, time range, auto-refresh for platform pages | `Layout.tsx` / DS `TopBar` | 122-128 |
|
||||||
|
| H2 | OrgResolver error state renders blank screen (no error UI) | `OrgResolver.tsx` | 88-89 |
|
||||||
|
| H3 | Hardcoded `text-white` colors break light theme | All pages | Multiple |
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
| # | Issue | File(s) | Line(s) |
|
||||||
|
|---|-------|---------|---------|
|
||||||
|
| M1 | No active state on sidebar navigation items | `Layout.tsx` | 79-108 |
|
||||||
|
| M2 | Breadcrumbs permanently empty | `Layout.tsx` | 124 |
|
||||||
|
| M3 | DashboardPage has no error handling for failed API calls | `DashboardPage.tsx` | 23-26 |
|
||||||
|
| M4 | AdminTenantsPage missing empty state | `AdminTenantsPage.tsx` | 67-72 |
|
||||||
|
| M5 | AdminTenantsPage row click silently switches tenant context | `AdminTenantsPage.tsx` | 47-57 |
|
||||||
|
| M6 | Toasts never used despite ToastProvider being mounted | All pages | - |
|
||||||
|
| M7 | Raw `<button>` and `<code>` instead of DS components in LicensePage | `LicensePage.tsx` | 166-178 |
|
||||||
|
| M8 | AdminTenantsPage `createdAt` column renders raw ISO string | `AdminTenantsPage.tsx` | 31 |
|
||||||
|
| M9 | `tierColor()` defined twice with different tier mappings | `DashboardPage.tsx`, `LicensePage.tsx` | 12-18, 25-33 |
|
||||||
|
| M10 | "Not included" feature badge uses `color="auto"` instead of muted/neutral | `LicensePage.tsx` | 133 |
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
| # | Issue | File(s) | Line(s) |
|
||||||
|
|---|-------|---------|---------|
|
||||||
|
| L1 | Three "Open Server Dashboard" buttons/links on dashboard | `Layout.tsx`, `DashboardPage.tsx` | 112-116, 81-87, 119-125 |
|
||||||
|
| L2 | Inconsistent loading style (inline styles vs Tailwind) | Auth files vs pages | Multiple |
|
||||||
|
| L3 | No Skeleton loading used (all Spinner) | All pages | - |
|
||||||
|
| L4 | Sidebar collapse disabled (no-op handler) | `Layout.tsx` | 71 |
|
||||||
|
| L5 | Sign-in page missing ThemeProvider wrapper | `sign-in/src/main.tsx` | 6-9 |
|
||||||
|
| L6 | Sign-in page has no forgot-password or sign-up link | `sign-in/src/SignInPage.tsx` | - |
|
||||||
|
| L7 | Custom SVG icons in Layout instead of lucide-react | `Layout.tsx` | 26-62 |
|
||||||
|
| L8 | Username null = no logout button visible | `Layout.tsx` | 125-126 |
|
||||||
|
| L9 | Page padding `p-6` repeated per-page instead of in Layout | All pages | - |
|
||||||
BIN
audit/verify-01-dashboard.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit/verify-02-license.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
4974
ci-docker-log.txt
Normal file
@@ -1,48 +0,0 @@
|
|||||||
# Development overrides: exposes ports for direct access
|
|
||||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
|
|
||||||
logto:
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
|
|
||||||
cameleer-saas:
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./ui/dist:/app/static
|
|
||||||
environment:
|
|
||||||
SPRING_PROFILES_ACTIVE: dev
|
|
||||||
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
|
||||||
|
|
||||||
cameleer3-server:
|
|
||||||
ports:
|
|
||||||
- "8081:8081"
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- jardata:/data/jars
|
|
||||||
group_add:
|
|
||||||
- "0"
|
|
||||||
environment:
|
|
||||||
CAMELEER_RUNTIME_ENABLED: "true"
|
|
||||||
CAMELEER_JAR_STORAGE_PATH: /data/jars
|
|
||||||
CAMELEER_RUNTIME_BASE_IMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:latest
|
|
||||||
CAMELEER_DOCKER_NETWORK: cameleer-saas_cameleer
|
|
||||||
CAMELEER_SERVER_URL: http://cameleer3-server:8081
|
|
||||||
CAMELEER_ROUTING_DOMAIN: ${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_ROUTING_MODE: path
|
|
||||||
CAMELEER_JAR_DOCKER_VOLUME: cameleer-saas_jardata
|
|
||||||
|
|
||||||
cameleer3-server-ui:
|
|
||||||
ports:
|
|
||||||
- "8082:80"
|
|
||||||
|
|
||||||
clickhouse:
|
|
||||||
ports:
|
|
||||||
- "8123:8123"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
jardata:
|
|
||||||
@@ -1,246 +1,23 @@
|
|||||||
|
# Dev overrides — layered on top of installer/templates/ via COMPOSE_FILE in .env
|
||||||
|
# Usage: docker compose up (reads .env automatically)
|
||||||
services:
|
services:
|
||||||
traefik-certs:
|
cameleer-postgres:
|
||||||
image: alpine:latest
|
|
||||||
restart: "no"
|
|
||||||
entrypoint: ["sh", "-c"]
|
|
||||||
command:
|
|
||||||
- |
|
|
||||||
if [ ! -f /certs/cert.pem ]; then
|
|
||||||
apk add --no-cache openssl >/dev/null 2>&1
|
|
||||||
openssl req -x509 -newkey rsa:4096 \
|
|
||||||
-keyout /certs/key.pem -out /certs/cert.pem \
|
|
||||||
-days 365 -nodes \
|
|
||||||
-subj "/CN=$$PUBLIC_HOST" \
|
|
||||||
-addext "subjectAltName=DNS:$$PUBLIC_HOST,DNS:*.$$PUBLIC_HOST"
|
|
||||||
echo "Generated self-signed cert for $$PUBLIC_HOST"
|
|
||||||
else
|
|
||||||
echo "Certs already exist, skipping"
|
|
||||||
fi
|
|
||||||
environment:
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
volumes:
|
|
||||||
- certs:/certs
|
|
||||||
|
|
||||||
traefik:
|
|
||||||
image: traefik:v3
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
traefik-certs:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "5432:5432"
|
||||||
- "443:443"
|
|
||||||
- "3002:3002"
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
|
||||||
- ./docker/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
||||||
- certs:/etc/traefik/certs:ro
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- cameleer-traefik
|
|
||||||
|
|
||||||
postgres:
|
cameleer-clickhouse:
|
||||||
image: postgres:16-alpine
|
ports:
|
||||||
restart: unless-stopped
|
- "8123:8123"
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
- ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
logto:
|
cameleer-logto:
|
||||||
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
|
ports:
|
||||||
restart: unless-stopped
|
- "3001:3001"
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
|
||||||
environment:
|
|
||||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
|
|
||||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:3002
|
|
||||||
TRUST_PROXY_HEADER: 1
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED: "0" # dev only — accept self-signed cert for internal OIDC discovery
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 15s
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.logto.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.logto.priority=1
|
|
||||||
- traefik.http.routers.logto.entrypoints=websecure
|
|
||||||
- traefik.http.routers.logto.tls=true
|
|
||||||
- traefik.http.routers.logto.service=logto
|
|
||||||
- traefik.http.routers.logto.middlewares=logto-cors
|
|
||||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:3002
|
|
||||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
|
||||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
|
||||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true
|
|
||||||
- traefik.http.services.logto.loadbalancer.server.port=3001
|
|
||||||
- traefik.http.routers.logto-console.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.logto-console.entrypoints=admin-console
|
|
||||||
- traefik.http.routers.logto-console.tls=true
|
|
||||||
- traefik.http.routers.logto-console.service=logto-console
|
|
||||||
- traefik.http.services.logto-console.loadbalancer.server.port=3002
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
logto-bootstrap:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
depends_on:
|
|
||||||
logto:
|
|
||||||
condition: service_healthy
|
|
||||||
cameleer3-server:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: "no"
|
|
||||||
entrypoint: ["sh", "/scripts/logto-bootstrap.sh"]
|
|
||||||
environment:
|
|
||||||
LOGTO_ENDPOINT: http://logto:3001
|
|
||||||
LOGTO_ADMIN_ENDPOINT: http://logto:3002
|
|
||||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
|
||||||
PG_HOST: postgres
|
|
||||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
|
||||||
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
|
|
||||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
|
||||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
|
|
||||||
TENANT_ADMIN_USER: ${TENANT_ADMIN_USER:-camel}
|
|
||||||
TENANT_ADMIN_PASS: ${TENANT_ADMIN_PASS:-camel}
|
|
||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
|
||||||
SERVER_ENDPOINT: http://cameleer3-server:8081
|
|
||||||
SERVER_UI_USER: ${CAMELEER_UI_USER:-admin}
|
|
||||||
SERVER_UI_PASS: ${CAMELEER_UI_PASSWORD:-admin}
|
|
||||||
volumes:
|
|
||||||
- ./docker/logto-bootstrap.sh:/scripts/logto-bootstrap.sh:ro
|
|
||||||
- bootstrapdata:/data
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
cameleer-saas:
|
cameleer-saas:
|
||||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
ports:
|
||||||
restart: unless-stopped
|
- "8080:8080"
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
logto-bootstrap:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
volumes:
|
volumes:
|
||||||
- bootstrapdata:/data/bootstrap:ro
|
- ./ui/dist:/app/static
|
||||||
environment:
|
environment:
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
||||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
|
|
||||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
LOGTO_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
|
|
||||||
LOGTO_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
|
|
||||||
LOGTO_M2M_CLIENT_ID: ${LOGTO_M2M_CLIENT_ID:-}
|
|
||||||
LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
|
|
||||||
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
|
||||||
- traefik.http.routers.saas.entrypoints=websecure
|
|
||||||
- traefik.http.routers.saas.tls=true
|
|
||||||
- traefik.http.services.saas.loadbalancer.server.port=8080
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
cameleer3-server:
|
|
||||||
image: ${CAMELEER3_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
clickhouse:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/cameleer3
|
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
|
||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
|
||||||
CAMELEER_JWT_SECRET: ${CAMELEER_JWT_SECRET:-cameleer-dev-jwt-secret-change-in-production}
|
|
||||||
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
|
||||||
CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
|
|
||||||
CAMELEER_OIDC_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
|
|
||||||
CAMELEER_OIDC_TLS_SKIP_VERIFY: "true" # dev only — disable in production with real certs
|
|
||||||
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
|
||||||
CAMELEER_CORS_ALLOWED_ORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 15s
|
|
||||||
labels:
|
|
||||||
- traefik.enable=false
|
|
||||||
networks:
|
|
||||||
cameleer:
|
|
||||||
cameleer-traefik:
|
|
||||||
aliases:
|
|
||||||
- cameleer3-server
|
|
||||||
|
|
||||||
cameleer3-server-ui:
|
|
||||||
image: ${CAMELEER3_SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
cameleer3-server:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
CAMELEER_API_URL: http://cameleer3-server:8081
|
|
||||||
BASE_PATH: /server
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.server-ui.rule=PathPrefix(`/server`)
|
|
||||||
- traefik.http.routers.server-ui.entrypoints=websecure
|
|
||||||
- traefik.http.routers.server-ui.tls=true
|
|
||||||
- traefik.http.routers.server-ui.middlewares=server-ui-strip
|
|
||||||
- traefik.http.middlewares.server-ui-strip.stripprefix.prefixes=/server
|
|
||||||
- traefik.http.routers.server-ui.service=server-ui
|
|
||||||
- traefik.http.services.server-ui.loadbalancer.server.port=80
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
clickhouse:
|
|
||||||
image: clickhouse/clickhouse-server:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- chdata:/var/lib/clickhouse
|
|
||||||
- ./docker/clickhouse-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
||||||
- ./docker/clickhouse-users.xml:/etc/clickhouse-server/users.d/default-user.xml:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
networks:
|
|
||||||
cameleer:
|
|
||||||
driver: bridge
|
|
||||||
cameleer-traefik:
|
|
||||||
name: cameleer-traefik
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
chdata:
|
|
||||||
certs:
|
|
||||||
bootstrapdata:
|
|
||||||
|
|||||||
93
docker/CLAUDE.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Docker & Infrastructure
|
||||||
|
|
||||||
|
## Routing (single-domain, path-based via Traefik)
|
||||||
|
|
||||||
|
All services on one hostname. Infrastructure containers (Traefik, Logto) use `PUBLIC_HOST` + `PUBLIC_PROTOCOL` env vars directly. The SaaS app reads these via `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` / `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` (Spring Boot properties `cameleer.saas.provisioning.publichost` / `cameleer.saas.provisioning.publicprotocol`).
|
||||||
|
|
||||||
|
| Path | Target | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) |
|
||||||
|
| `/platform/vendor/*` | (SPA routes) | Vendor console (platform:admin) |
|
||||||
|
| `/platform/tenant/*` | (SPA routes) | Tenant admin portal (org-scoped) |
|
||||||
|
| `/t/{slug}/*` | per-tenant server-ui | Provisioned tenant UI containers (Traefik labels) |
|
||||||
|
| `/` | redirect -> `/platform/` | Via `docker/traefik-dynamic.yml` |
|
||||||
|
| `/*` (catch-all) | cameleer-logto:3001 (priority=1) | Custom sign-in UI, OIDC, interaction |
|
||||||
|
|
||||||
|
- SPA assets at `/_app/` (Vite `assetsDir: '_app'`) to avoid conflict with Logto's `/assets/`
|
||||||
|
- Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain, same origin)
|
||||||
|
- TLS: `traefik-certs` init container generates self-signed cert (dev) or copies user-supplied cert via `CERT_FILE`/`KEY_FILE`/`CA_FILE` env vars. Default cert configured in `docker/traefik-dynamic.yml` (NOT static `traefik.yml` — Traefik v3 ignores `tls.stores.default` in static config). Runtime cert replacement via vendor UI (stage/activate/restore). ACME for production (future). Server containers import `/certs/ca.pem` into JVM truststore at startup via `docker-entrypoint.sh` for OIDC trust.
|
||||||
|
- Root `/` -> `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`)
|
||||||
|
- LoginPage auto-redirects to Logto OIDC (no intermediate button)
|
||||||
|
- Per-tenant server containers get Traefik labels for `/t/{slug}/*` routing at provisioning time
|
||||||
|
|
||||||
|
## Docker Networks
|
||||||
|
|
||||||
|
Compose-defined networks:
|
||||||
|
|
||||||
|
| Network | Name on Host | Purpose |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| `cameleer` | `cameleer-saas_cameleer` | Compose default — shared services (DB, Logto, SaaS) |
|
||||||
|
| `cameleer-traefik` | `cameleer-traefik` (fixed `name:`) | Traefik + provisioned tenant containers |
|
||||||
|
|
||||||
|
Per-tenant networks (created dynamically by `DockerTenantProvisioner`):
|
||||||
|
|
||||||
|
| Network | Name Pattern | Purpose |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| Tenant network | `cameleer-tenant-{slug}` | Internal bridge, no internet — isolates tenant server + apps |
|
||||||
|
| Environment network | `cameleer-env-{tenantId}-{envSlug}` | Tenant-scoped (includes tenantId to prevent slug collision across tenants) |
|
||||||
|
|
||||||
|
Server containers join three networks: tenant network (primary), shared services network (`cameleer`), and traefik network. Apps deployed by the server use the tenant network as primary.
|
||||||
|
|
||||||
|
**Backend IP resolution:** Traefik's Docker provider is configured with `network: cameleer-traefik` (static `traefik.yml`). Every cameleer-managed container — saas-provisioned tenant containers (via `DockerTenantProvisioner`) and cameleer-server's per-app containers (via `DockerNetworkManager`) — is attached to `cameleer-traefik` at creation, so Traefik always resolves a reachable backend IP. Provisioned tenant containers additionally emit a `traefik.docker.network=cameleer-traefik` label as per-service defense-in-depth. (Pre-2026-04-23 the static config pointed at `network: cameleer`, a name that never matched any real network — that produced 504 Gateway Timeout on every managed app until the Traefik image was rebuilt.)
|
||||||
|
|
||||||
|
## Custom sign-in UI (`ui/sign-in/`)
|
||||||
|
|
||||||
|
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration (registration is disabled by default until the vendor admin configures an email connector via the UI).
|
||||||
|
|
||||||
|
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||||
|
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
||||||
|
- **Sign-in**: Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect). Auto-detects email vs username identifier.
|
||||||
|
- **Registration**: 2-phase flow. Phase 1: init Register -> send verification code to email. Phase 2: verify code -> set password -> identify (creates user) -> submit -> redirect.
|
||||||
|
- Reads `first_screen=register` from URL query params to show register form initially (set by `@logto/react` SDK's `firstScreen` option)
|
||||||
|
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
||||||
|
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
||||||
|
|
||||||
|
## Deployment pipeline
|
||||||
|
|
||||||
|
App deployment is handled by the cameleer-server's `DeploymentExecutor` (7-stage async flow):
|
||||||
|
1. PRE_FLIGHT — validate config, check JAR exists
|
||||||
|
2. PULL_IMAGE — pull base image if missing
|
||||||
|
3. CREATE_NETWORK — ensure cameleer-traefik and cameleer-env-{slug} networks
|
||||||
|
4. START_REPLICAS — create N containers with Traefik labels
|
||||||
|
5. HEALTH_CHECK — poll `/cameleer/health` on agent port 9464
|
||||||
|
6. SWAP_TRAFFIC — stop old deployment (blue/green)
|
||||||
|
7. COMPLETE — mark RUNNING or DEGRADED
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
- `DeploymentExecutor.java` (in cameleer-server) — async staged deployment, runtime type auto-detection
|
||||||
|
- `DockerRuntimeOrchestrator.java` (in cameleer-server) — Docker client, container lifecycle, builds runtime-type-specific entrypoints (spring-boot uses `-cp` + `PropertiesLauncher` with `-Dloader.path` for log appender; quarkus uses `-jar`; plain-java uses `-cp` + detected main class; native exec directly). Overrides the Dockerfile ENTRYPOINT.
|
||||||
|
- `docker/runtime-base/Dockerfile` — base image with agent JAR + `cameleer-log-appender.jar` + JRE. The Dockerfile ENTRYPOINT (`-jar /app/app.jar`) is a fallback — `DockerRuntimeOrchestrator` overrides it at container creation.
|
||||||
|
- `RuntimeDetector.java` (in cameleer-server) — detects runtime type from JAR manifest `Main-Class`; derives correct `PropertiesLauncher` package (Spring Boot 3.2+ vs pre-3.2)
|
||||||
|
- `ServerApiClient.java` — M2M token acquisition for SaaS->server API calls (agent status). Uses `X-Cameleer-Protocol-Version: 1` header
|
||||||
|
- Docker socket access: `group_add: ["0"]` in docker-compose.dev.yml (not root group membership in Dockerfile)
|
||||||
|
- Network: deployed containers join `cameleer-tenant-{slug}` (primary, isolation) + `cameleer-traefik` (routing) + `cameleer-env-{tenantId}-{envSlug}` (environment isolation)
|
||||||
|
|
||||||
|
## Bootstrap (`docker/logto-bootstrap.sh`)
|
||||||
|
|
||||||
|
Idempotent script run inside the Logto container entrypoint. **Clean slate** — no example tenant, no viewer user, no server configuration. Phases:
|
||||||
|
1. Wait for Logto health (no server to wait for — servers are provisioned per-tenant)
|
||||||
|
2. Get Management API token (reads `m-default` secret from DB)
|
||||||
|
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
|
||||||
|
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
|
||||||
|
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
||||||
|
5. Create admin user (SaaS admin with Logto console access). `SAAS_ADMIN_USER` is the admin's email address in SaaS mode — used as both the Logto username and primaryEmail. No separate `SAAS_ADMIN_EMAIL`.
|
||||||
|
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
||||||
|
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||||
|
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
|
||||||
|
9. Cleanup seeded Logto apps
|
||||||
|
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
||||||
|
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
||||||
|
|
||||||
|
SMTP / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI.
|
||||||
|
|
||||||
|
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
||||||
4
docker/cameleer-clickhouse/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM clickhouse/clickhouse-server:latest
|
||||||
|
COPY init.sql /docker-entrypoint-initdb.d/init.sql
|
||||||
|
COPY users.xml /etc/clickhouse-server/users.d/default-user.xml
|
||||||
|
COPY prometheus.xml /etc/clickhouse-server/config.d/prometheus.xml
|
||||||
9
docker/cameleer-clickhouse/prometheus.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<clickhouse>
|
||||||
|
<prometheus>
|
||||||
|
<endpoint>/metrics</endpoint>
|
||||||
|
<port>9363</port>
|
||||||
|
<metrics>true</metrics>
|
||||||
|
<events>true</events>
|
||||||
|
<asynchronous_metrics>true</asynchronous_metrics>
|
||||||
|
</prometheus>
|
||||||
|
</clickhouse>
|
||||||
16
docker/cameleer-clickhouse/users.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<clickhouse>
|
||||||
|
<users>
|
||||||
|
<default remove="remove">
|
||||||
|
</default>
|
||||||
|
|
||||||
|
<default>
|
||||||
|
<profile>default</profile>
|
||||||
|
<networks>
|
||||||
|
<ip>::/0</ip>
|
||||||
|
</networks>
|
||||||
|
<password from_env="CLICKHOUSE_PASSWORD" />
|
||||||
|
<quota>default</quota>
|
||||||
|
<access_management>0</access_management>
|
||||||
|
</default>
|
||||||
|
</users>
|
||||||
|
</clickhouse>
|
||||||
73
docker/cameleer-logto/logto-entrypoint.sh
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Build DB_URL from individual env vars so passwords with special characters
|
||||||
|
# are properly URL-encoded (Logto only accepts a connection string)
|
||||||
|
if [ -z "$DB_URL" ]; then
|
||||||
|
ENCODED_PW=$(node -e "process.stdout.write(encodeURIComponent(process.env.PG_PASSWORD || ''))")
|
||||||
|
export DB_URL="postgres://${PG_USER:-cameleer}:${ENCODED_PW}@${PG_HOST:-localhost}:5432/logto"
|
||||||
|
echo "[entrypoint] Built DB_URL from PG_USER/PG_PASSWORD/PG_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save the real public endpoints for after bootstrap
|
||||||
|
REAL_ENDPOINT="$ENDPOINT"
|
||||||
|
REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT"
|
||||||
|
|
||||||
|
echo "[entrypoint] Seeding Logto database..."
|
||||||
|
npm run cli db seed -- --swe 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[entrypoint] Deploying database alterations..."
|
||||||
|
npm run cli db alteration deploy 2>/dev/null || true
|
||||||
|
|
||||||
|
# Start Logto with localhost endpoints so it can reach itself without Traefik
|
||||||
|
export ENDPOINT="http://localhost:3001"
|
||||||
|
export ADMIN_ENDPOINT="http://localhost:3002"
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting Logto (bootstrap mode)..."
|
||||||
|
npm start &
|
||||||
|
LOGTO_PID=$!
|
||||||
|
|
||||||
|
echo "[entrypoint] Waiting for Logto to be ready..."
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
if node -e "require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; then
|
||||||
|
echo "[entrypoint] Logto is ready."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 120 ]; then
|
||||||
|
echo "[entrypoint] ERROR: Logto not ready after 120s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run bootstrap — use localhost endpoints, skip Host headers (BOOTSTRAP_LOCAL flag)
|
||||||
|
# PUBLIC_HOST and PUBLIC_PROTOCOL stay real for redirect URI generation
|
||||||
|
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
||||||
|
export LOGTO_ENDPOINT="http://localhost:3001"
|
||||||
|
export LOGTO_ADMIN_ENDPOINT="http://localhost:3002"
|
||||||
|
export BOOTSTRAP_LOCAL="true"
|
||||||
|
|
||||||
|
if [ -f "$BOOTSTRAP_FILE" ]; then
|
||||||
|
CACHED_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
||||||
|
CACHED_SPA=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
||||||
|
if [ -n "$CACHED_SECRET" ] && [ -n "$CACHED_SPA" ]; then
|
||||||
|
echo "[entrypoint] Bootstrap already complete."
|
||||||
|
else
|
||||||
|
echo "[entrypoint] Incomplete bootstrap found, re-running..."
|
||||||
|
/scripts/logto-bootstrap.sh
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[entrypoint] Running bootstrap..."
|
||||||
|
/scripts/logto-bootstrap.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart Logto with real public endpoints
|
||||||
|
echo "[entrypoint] Bootstrap done. Restarting Logto with public endpoints..."
|
||||||
|
kill $LOGTO_PID 2>/dev/null || true
|
||||||
|
wait $LOGTO_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
export ENDPOINT="$REAL_ENDPOINT"
|
||||||
|
export ADMIN_ENDPOINT="$REAL_ADMIN_ENDPOINT"
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting Logto (production mode)..."
|
||||||
|
exec npm start
|
||||||
3
docker/cameleer-postgres/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM postgres:16-alpine
|
||||||
|
COPY init-databases.sh /docker-entrypoint-initdb.d/init-databases.sh
|
||||||
|
RUN chmod +x /docker-entrypoint-initdb.d/init-databases.sh
|
||||||
@@ -3,7 +3,7 @@ set -e
|
|||||||
|
|
||||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
CREATE DATABASE logto;
|
CREATE DATABASE logto;
|
||||||
CREATE DATABASE cameleer3;
|
CREATE DATABASE cameleer;
|
||||||
GRANT ALL PRIVILEGES ON DATABASE logto TO $POSTGRES_USER;
|
GRANT ALL PRIVILEGES ON DATABASE logto TO $POSTGRES_USER;
|
||||||
GRANT ALL PRIVILEGES ON DATABASE cameleer3 TO $POSTGRES_USER;
|
GRANT ALL PRIVILEGES ON DATABASE cameleer TO $POSTGRES_USER;
|
||||||
EOSQL
|
EOSQL
|
||||||
7
docker/cameleer-traefik/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM traefik:v3
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
COPY traefik.yml /etc/traefik/traefik.yml
|
||||||
|
COPY traefik-dynamic.yml /etc/traefik/dynamic.yml
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
68
docker/cameleer-traefik/entrypoint.sh
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CERTS_DIR="/certs"
|
||||||
|
|
||||||
|
# Skip if certs already exist (idempotent)
|
||||||
|
if [ ! -f "$CERTS_DIR/cert.pem" ]; then
|
||||||
|
mkdir -p "$CERTS_DIR"
|
||||||
|
|
||||||
|
if [ -n "$CERT_FILE" ] && [ -n "$KEY_FILE" ]; then
|
||||||
|
# User-supplied certificate
|
||||||
|
echo "[certs] Installing user-supplied certificate..."
|
||||||
|
cp "$CERT_FILE" "$CERTS_DIR/cert.pem"
|
||||||
|
cp "$KEY_FILE" "$CERTS_DIR/key.pem"
|
||||||
|
if [ -n "$CA_FILE" ]; then
|
||||||
|
cp "$CA_FILE" "$CERTS_DIR/ca.pem"
|
||||||
|
fi
|
||||||
|
# Validate key matches cert
|
||||||
|
CERT_MOD=$(openssl x509 -noout -modulus -in "$CERTS_DIR/cert.pem" 2>/dev/null | md5sum)
|
||||||
|
KEY_MOD=$(openssl rsa -noout -modulus -in "$CERTS_DIR/key.pem" 2>/dev/null | md5sum)
|
||||||
|
if [ "$CERT_MOD" != "$KEY_MOD" ]; then
|
||||||
|
echo "[certs] ERROR: Certificate and key do not match!"
|
||||||
|
rm -f "$CERTS_DIR/cert.pem" "$CERTS_DIR/key.pem" "$CERTS_DIR/ca.pem"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SELF_SIGNED=false
|
||||||
|
echo "[certs] Installed user-supplied certificate."
|
||||||
|
else
|
||||||
|
# Generate self-signed certificate
|
||||||
|
HOST="${PUBLIC_HOST:-localhost}"
|
||||||
|
AUTH="${AUTH_HOST:-$HOST}"
|
||||||
|
echo "[certs] Generating self-signed certificate for $HOST..."
|
||||||
|
# Build SAN list; deduplicate when AUTH_HOST equals PUBLIC_HOST
|
||||||
|
if [ "$AUTH" = "$HOST" ]; then
|
||||||
|
SAN="DNS:$HOST,DNS:*.$HOST"
|
||||||
|
else
|
||||||
|
SAN="DNS:$HOST,DNS:*.$HOST,DNS:$AUTH,DNS:*.$AUTH"
|
||||||
|
echo "[certs] (+ auth domain: $AUTH)"
|
||||||
|
fi
|
||||||
|
openssl req -x509 -newkey rsa:4096 \
|
||||||
|
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
|
||||||
|
-days 365 -nodes \
|
||||||
|
-subj "/CN=$HOST" \
|
||||||
|
-addext "subjectAltName=$SAN"
|
||||||
|
SELF_SIGNED=true
|
||||||
|
echo "[certs] Generated self-signed certificate for $HOST."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write metadata for SaaS app to seed DB
|
||||||
|
SUBJECT=$(openssl x509 -noout -subject -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/subject=//')
|
||||||
|
FINGERPRINT=$(openssl x509 -noout -fingerprint -sha256 -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/.*=//')
|
||||||
|
NOT_BEFORE=$(openssl x509 -noout -startdate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notBefore=//')
|
||||||
|
NOT_AFTER=$(openssl x509 -noout -enddate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notAfter=//')
|
||||||
|
HAS_CA=false
|
||||||
|
[ -f "$CERTS_DIR/ca.pem" ] && HAS_CA=true
|
||||||
|
cat > "$CERTS_DIR/meta.json" <<METAEOF
|
||||||
|
{"subject":"$SUBJECT","fingerprint":"$FINGERPRINT","selfSigned":$SELF_SIGNED,"hasCa":$HAS_CA,"notBefore":"$NOT_BEFORE","notAfter":"$NOT_AFTER"}
|
||||||
|
METAEOF
|
||||||
|
|
||||||
|
mkdir -p "$CERTS_DIR/staged" "$CERTS_DIR/prev"
|
||||||
|
chmod 775 "$CERTS_DIR" "$CERTS_DIR/staged" "$CERTS_DIR/prev"
|
||||||
|
chmod 660 "$CERTS_DIR"/*.pem 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "[certs] Certificates already exist, skipping generation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Traefik
|
||||||
|
exec traefik "$@"
|
||||||
6
docker/cameleer-traefik/traefik-dynamic.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
tls:
|
||||||
|
stores:
|
||||||
|
default:
|
||||||
|
defaultCertificate:
|
||||||
|
certFile: /certs/cert.pem
|
||||||
|
keyFile: /certs/key.pem
|
||||||
@@ -18,13 +18,6 @@ providers:
|
|||||||
docker:
|
docker:
|
||||||
endpoint: "unix:///var/run/docker.sock"
|
endpoint: "unix:///var/run/docker.sock"
|
||||||
exposedByDefault: false
|
exposedByDefault: false
|
||||||
network: cameleer
|
network: cameleer-traefik
|
||||||
file:
|
file:
|
||||||
filename: /etc/traefik/dynamic.yml
|
filename: /etc/traefik/dynamic.yml
|
||||||
|
|
||||||
tls:
|
|
||||||
stores:
|
|
||||||
default:
|
|
||||||
defaultCertificate:
|
|
||||||
certFile: /etc/traefik/certs/cert.pem
|
|
||||||
keyFile: /etc/traefik/certs/key.pem
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<clickhouse>
|
|
||||||
<users>
|
|
||||||
<default>
|
|
||||||
<networks>
|
|
||||||
<ip>::/0</ip>
|
|
||||||
</networks>
|
|
||||||
</default>
|
|
||||||
</users>
|
|
||||||
</clickhouse>
|
|
||||||
@@ -4,15 +4,15 @@ set -e
|
|||||||
# Cameleer SaaS — Bootstrap Script
|
# Cameleer SaaS — Bootstrap Script
|
||||||
# Creates Logto apps, users, organizations, roles.
|
# Creates Logto apps, users, organizations, roles.
|
||||||
# Seeds cameleer_saas DB with tenant, environment, license.
|
# Seeds cameleer_saas DB with tenant, environment, license.
|
||||||
# Configures cameleer3-server OIDC.
|
# Configures cameleer-server OIDC.
|
||||||
# Idempotent: checks existence before creating.
|
# Idempotent: checks existence before creating.
|
||||||
|
|
||||||
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
|
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://cameleer-logto:3001}"
|
||||||
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}"
|
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://cameleer-logto:3002}"
|
||||||
LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}"
|
LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}"
|
||||||
MGMT_API_RESOURCE="https://default.logto.app/api"
|
MGMT_API_RESOURCE="https://default.logto.app/api"
|
||||||
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
||||||
PG_HOST="${PG_HOST:-postgres}"
|
PG_HOST="${PG_HOST:-cameleer-postgres}"
|
||||||
PG_USER="${PG_USER:-cameleer}"
|
PG_USER="${PG_USER:-cameleer}"
|
||||||
PG_DB_LOGTO="logto"
|
PG_DB_LOGTO="logto"
|
||||||
PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}"
|
PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}"
|
||||||
@@ -25,34 +25,52 @@ API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
|||||||
API_RESOURCE_NAME="Cameleer SaaS API"
|
API_RESOURCE_NAME="Cameleer SaaS API"
|
||||||
|
|
||||||
# Users (configurable via env vars)
|
# Users (configurable via env vars)
|
||||||
|
# In SaaS mode, SAAS_ADMIN_USER is the admin's email address (e.g. admin@company.com).
|
||||||
|
# The local part (before @) is used as the Logto username; the full value as primaryEmail.
|
||||||
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
||||||
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
||||||
TENANT_ADMIN_USER="${TENANT_ADMIN_USER:-camel}"
|
# Extract username (local part) for Logto — Logto rejects @ in usernames
|
||||||
TENANT_ADMIN_PASS="${TENANT_ADMIN_PASS:-camel}"
|
if echo "$SAAS_ADMIN_USER" | grep -q '@'; then
|
||||||
|
ADMIN_USERNAME="${SAAS_ADMIN_USER%%@*}"
|
||||||
|
ADMIN_EMAIL="$SAAS_ADMIN_USER"
|
||||||
|
else
|
||||||
|
ADMIN_USERNAME="$SAAS_ADMIN_USER"
|
||||||
|
ADMIN_EMAIL=""
|
||||||
|
fi
|
||||||
|
|
||||||
# Tenant config
|
# No server config — servers are provisioned dynamically by the admin console
|
||||||
TENANT_NAME="Example Tenant"
|
|
||||||
TENANT_SLUG="default"
|
|
||||||
BOOTSTRAP_TOKEN="${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}"
|
|
||||||
|
|
||||||
# Server config
|
|
||||||
SERVER_ENDPOINT="${SERVER_ENDPOINT:-http://cameleer3-server:8081}"
|
|
||||||
SERVER_UI_USER="${SERVER_UI_USER:-admin}"
|
|
||||||
SERVER_UI_PASS="${SERVER_UI_PASS:-admin}"
|
|
||||||
|
|
||||||
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
||||||
HOST="${PUBLIC_HOST:-localhost}"
|
HOST="${PUBLIC_HOST:-localhost}"
|
||||||
|
AUTH="${AUTH_HOST:-$HOST}"
|
||||||
PROTO="${PUBLIC_PROTOCOL:-https}"
|
PROTO="${PUBLIC_PROTOCOL:-https}"
|
||||||
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
||||||
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\"]"
|
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
|
||||||
TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\",\"${PROTO}://${HOST}/server/oidc/callback\"]"
|
TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\",\"${PROTO}://${HOST}/server/oidc/callback\"]"
|
||||||
TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\",\"${PROTO}://${HOST}/server\",\"${PROTO}://${HOST}/server/login?local\"]"
|
TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\",\"${PROTO}://${HOST}/server\",\"${PROTO}://${HOST}/server/login?local\"]"
|
||||||
|
|
||||||
log() { echo "[bootstrap] $1"; }
|
log() { echo "[bootstrap] $1"; }
|
||||||
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
|
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
|
||||||
|
|
||||||
# Install jq + curl
|
# When BOOTSTRAP_LOCAL=true (running inside Logto container with localhost endpoints),
|
||||||
apk add --no-cache jq curl >/dev/null 2>&1
|
# skip Host/X-Forwarded-Proto headers — they cause issuer mismatches with localhost
|
||||||
|
if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
|
||||||
|
HOST_ARGS=""
|
||||||
|
ADMIN_HOST_ARGS=""
|
||||||
|
else
|
||||||
|
# Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
|
||||||
|
HOST_ARGS="-H Host:${AUTH}"
|
||||||
|
ADMIN_HOST_ARGS="-H Host:${AUTH}:3002 -H X-Forwarded-Proto:https"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install jq + curl if not already available (deps are baked into cameleer-logto image)
|
||||||
|
if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
|
||||||
|
if command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache jq curl >/dev/null 2>&1
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq && apt-get install -y -qq jq curl >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Read cached secrets from previous run
|
# Read cached secrets from previous run
|
||||||
if [ -f "$BOOTSTRAP_FILE" ]; then
|
if [ -f "$BOOTSTRAP_FILE" ]; then
|
||||||
@@ -80,15 +98,7 @@ for i in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
log "Waiting for cameleer3-server..."
|
# No server wait — servers are provisioned dynamically by the admin console
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
|
|
||||||
log "cameleer3-server is ready."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
[ "$i" -eq 60 ] && { log "WARNING: cameleer3-server not ready after 60s — skipping OIDC config"; }
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 2: Get Management API token
|
# PHASE 2: Get Management API token
|
||||||
@@ -103,15 +113,14 @@ M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
|
|||||||
get_admin_token() {
|
get_admin_token() {
|
||||||
curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
-H "Host: ${HOST}:3002" \
|
$ADMIN_HOST_ARGS \
|
||||||
-H "X-Forwarded-Proto: https" \
|
|
||||||
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
||||||
}
|
}
|
||||||
|
|
||||||
get_default_token() {
|
get_default_token() {
|
||||||
curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
|
curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
-H "Host: ${HOST}" \
|
$HOST_ARGS \
|
||||||
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +133,7 @@ log "Got Management API token."
|
|||||||
# Verify Management API is fully ready (Logto may still be initializing internally)
|
# Verify Management API is fully ready (Logto may still be initializing internally)
|
||||||
log "Verifying Management API is responsive..."
|
log "Verifying Management API is responsive..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null)
|
VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null)
|
||||||
if echo "$VERIFY_RESPONSE" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
if echo "$VERIFY_RESPONSE" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||||
log "Management API is ready."
|
log "Management API is ready."
|
||||||
break
|
break
|
||||||
@@ -135,21 +144,21 @@ done
|
|||||||
|
|
||||||
# --- Helper: Logto API calls ---
|
# --- Helper: Logto API calls ---
|
||||||
api_get() {
|
api_get() {
|
||||||
curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
curl -s -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
||||||
}
|
}
|
||||||
api_post() {
|
api_post() {
|
||||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
||||||
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
api_put() {
|
api_put() {
|
||||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
|
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
||||||
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
api_delete() {
|
api_delete() {
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
api_patch() {
|
api_patch() {
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
|
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
||||||
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +186,7 @@ else
|
|||||||
log "Created SPA app: $SPA_ID"
|
log "Created SPA app: $SPA_ID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Traditional Web App (for cameleer3-server OIDC) ---
|
# --- Traditional Web App (for cameleer-server OIDC) ---
|
||||||
TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id")
|
TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id")
|
||||||
TRAD_SECRET=""
|
TRAD_SECRET=""
|
||||||
if [ -n "$TRAD_ID" ]; then
|
if [ -n "$TRAD_ID" ]; then
|
||||||
@@ -342,8 +351,7 @@ fi
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# --- Organization roles: owner, operator, viewer ---
|
# --- Organization roles: owner, operator, viewer ---
|
||||||
# Note: platform-admin / saas-vendor global role is NOT created here.
|
# Note: saas-vendor global role is created in Phase 12 and assigned to the admin user.
|
||||||
# It is injected via docker/vendor-seed.sh on the hosted SaaS environment only.
|
|
||||||
log "Creating organization roles..."
|
log "Creating organization roles..."
|
||||||
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
|
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
|
||||||
|
|
||||||
@@ -391,21 +399,27 @@ log "API resource scopes assigned to organization roles."
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# --- Platform Owner ---
|
# --- Platform Owner ---
|
||||||
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
|
log "Checking for platform owner user '$ADMIN_USERNAME'..."
|
||||||
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
|
ADMIN_USER_ID=$(api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id")
|
||||||
if [ -n "$ADMIN_USER_ID" ]; then
|
if [ -n "$ADMIN_USER_ID" ]; then
|
||||||
log "Platform owner exists: $ADMIN_USER_ID"
|
log "Platform owner exists: $ADMIN_USER_ID"
|
||||||
else
|
else
|
||||||
log "Creating platform owner '$SAAS_ADMIN_USER'..."
|
# Build user JSON — include primaryEmail only if SAAS_ADMIN_USER is an email
|
||||||
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
ADMIN_USER_JSON="{\"username\": \"$ADMIN_USERNAME\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Owner\""
|
||||||
\"username\": \"$SAAS_ADMIN_USER\",
|
if [ -n "$ADMIN_EMAIL" ]; then
|
||||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
ADMIN_USER_JSON="$ADMIN_USER_JSON, \"primaryEmail\": \"$ADMIN_EMAIL\""
|
||||||
\"name\": \"Platform Owner\"
|
log "Creating platform owner '$ADMIN_USERNAME' (email: $ADMIN_EMAIL)..."
|
||||||
}")
|
else
|
||||||
|
log "Creating platform owner '$ADMIN_USERNAME'..."
|
||||||
|
fi
|
||||||
|
ADMIN_USER_JSON="$ADMIN_USER_JSON}"
|
||||||
|
ADMIN_RESPONSE=$(api_post "/api/users" "$ADMIN_USER_JSON")
|
||||||
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
||||||
log "Created platform owner: $ADMIN_USER_ID"
|
if [ -z "$ADMIN_USER_ID" ] || [ "$ADMIN_USER_ID" = "null" ]; then
|
||||||
# No global role assigned — owner role is org-scoped.
|
log "ERROR: Failed to create platform owner. Response: $(echo "$ADMIN_RESPONSE" | head -c 300)"
|
||||||
# SaaS vendor role is injected via docker/vendor-seed.sh on hosted environments.
|
else
|
||||||
|
log "Created platform owner: $ADMIN_USER_ID"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
||||||
@@ -422,8 +436,7 @@ if [ -z "$M_ADMIN_SECRET" ]; then
|
|||||||
else
|
else
|
||||||
ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
-H "Host: ${HOST}:3002" \
|
$ADMIN_HOST_ARGS \
|
||||||
-H "X-Forwarded-Proto: https" \
|
|
||||||
-d "grant_type=client_credentials&client_id=m-admin&client_secret=${M_ADMIN_SECRET}&resource=${ADMIN_MGMT_RESOURCE}&scope=all")
|
-d "grant_type=client_credentials&client_id=m-admin&client_secret=${M_ADMIN_SECRET}&resource=${ADMIN_MGMT_RESOURCE}&scope=all")
|
||||||
ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
||||||
|
|
||||||
@@ -435,23 +448,23 @@ else
|
|||||||
|
|
||||||
# Admin-tenant API helpers (port 3002, admin token)
|
# Admin-tenant API helpers (port 3002, admin token)
|
||||||
admin_api_get() {
|
admin_api_get() {
|
||||||
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $ADMIN_HOST_ARGS "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
||||||
}
|
}
|
||||||
admin_api_post() {
|
admin_api_post() {
|
||||||
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \
|
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" $ADMIN_HOST_ARGS \
|
||||||
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
admin_api_patch() {
|
admin_api_patch() {
|
||||||
curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \
|
curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" $ADMIN_HOST_ARGS \
|
||||||
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if admin user already exists on admin tenant
|
# Check if admin user already exists on admin tenant (uses ADMIN_USERNAME, not email)
|
||||||
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null)
|
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id" 2>/dev/null)
|
||||||
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
|
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
|
||||||
log "Creating admin console user '$SAAS_ADMIN_USER'..."
|
log "Creating admin console user '$ADMIN_USERNAME'..."
|
||||||
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
|
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
|
||||||
\"username\": \"$SAAS_ADMIN_USER\",
|
\"username\": \"$ADMIN_USERNAME\",
|
||||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||||
\"name\": \"Platform Admin\"
|
\"name\": \"Platform Admin\"
|
||||||
}")
|
}")
|
||||||
@@ -479,16 +492,31 @@ if [ -n "$ADMIN_TENANT_USER_ID" ] && [ "$ADMIN_TENANT_USER_ID" != "null" ]; then
|
|||||||
log "WARNING: admin tenant roles not found"
|
log "WARNING: admin tenant roles not found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add to t-default organization with admin role
|
# Switch sign-in mode from Register to SignIn (admin user already created)
|
||||||
admin_api_post "/api/organizations/t-default/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1
|
|
||||||
TENANT_ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id')
|
|
||||||
if [ -n "$TENANT_ADMIN_ORG_ROLE_ID" ] && [ "$TENANT_ADMIN_ORG_ROLE_ID" != "null" ]; then
|
|
||||||
admin_api_post "/api/organizations/t-default/users/$ADMIN_TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$TENANT_ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1
|
|
||||||
log "Added to t-default organization with admin role."
|
|
||||||
fi
|
|
||||||
# Switch admin tenant sign-in mode from Register to SignIn (user already created)
|
|
||||||
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
|
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
|
||||||
log "Set admin tenant sign-in mode to SignIn."
|
log "Set sign-in mode to SignIn."
|
||||||
|
|
||||||
|
# Register admin-console redirect URIs (Logto ships with empty URIs)
|
||||||
|
ADMIN_PUBLIC="${ADMIN_ENDPOINT:-${PROTO}://${HOST}:3002}"
|
||||||
|
admin_api_patch "/api/applications/admin-console" "{
|
||||||
|
\"oidcClientMetadata\": {
|
||||||
|
\"redirectUris\": [\"${ADMIN_PUBLIC}/console/callback\"],
|
||||||
|
\"postLogoutRedirectUris\": [\"${ADMIN_PUBLIC}/console\"]
|
||||||
|
}
|
||||||
|
}" >/dev/null 2>&1
|
||||||
|
log "Registered admin-console redirect URIs."
|
||||||
|
|
||||||
|
# Add admin user to Logto's internal organizations (required for console login)
|
||||||
|
for ORG_ID in t-default t-admin; do
|
||||||
|
admin_api_post "/api/organizations/${ORG_ID}/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1
|
||||||
|
done
|
||||||
|
ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id')
|
||||||
|
if [ -n "$ADMIN_ORG_ROLE_ID" ] && [ "$ADMIN_ORG_ROLE_ID" != "null" ]; then
|
||||||
|
for ORG_ID in t-default t-admin; do
|
||||||
|
admin_api_post "/api/organizations/${ORG_ID}/users/${ADMIN_TENANT_USER_ID}/roles" "{\"organizationRoleIds\": [\"$ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
log "Added admin to Logto console organizations."
|
||||||
|
|
||||||
log "SaaS admin granted Logto console access."
|
log "SaaS admin granted Logto console access."
|
||||||
else
|
else
|
||||||
@@ -498,130 +526,10 @@ fi
|
|||||||
fi # end: ADMIN_TOKEN check
|
fi # end: ADMIN_TOKEN check
|
||||||
fi # end: M_ADMIN_SECRET check
|
fi # end: M_ADMIN_SECRET check
|
||||||
|
|
||||||
# --- Viewer user (for testing read-only OIDC role in server) ---
|
# No viewer user — tenant users are created by the admin during tenant provisioning.
|
||||||
log "Checking for viewer user '$TENANT_ADMIN_USER'..."
|
# No example organization — tenants are created via the admin console.
|
||||||
TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id")
|
# No server OIDC config — each provisioned server gets OIDC from env vars.
|
||||||
if [ -n "$TENANT_USER_ID" ]; then
|
ORG_ID=""
|
||||||
log "Viewer user exists: $TENANT_USER_ID"
|
|
||||||
else
|
|
||||||
log "Creating viewer user '$TENANT_ADMIN_USER'..."
|
|
||||||
TENANT_RESPONSE=$(api_post "/api/users" "{
|
|
||||||
\"username\": \"$TENANT_ADMIN_USER\",
|
|
||||||
\"password\": \"$TENANT_ADMIN_PASS\",
|
|
||||||
\"name\": \"Viewer\"
|
|
||||||
}")
|
|
||||||
TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id')
|
|
||||||
log "Created viewer user: $TENANT_USER_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# PHASE 6: Create organization + add users
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
log "Checking for organization '$TENANT_NAME'..."
|
|
||||||
EXISTING_ORGS=$(api_get "/api/organizations")
|
|
||||||
ORG_ID=$(echo "$EXISTING_ORGS" | jq -r ".[] | select(.name == \"$TENANT_NAME\") | .id")
|
|
||||||
|
|
||||||
if [ -n "$ORG_ID" ]; then
|
|
||||||
log "Organization exists: $ORG_ID"
|
|
||||||
else
|
|
||||||
log "Creating organization '$TENANT_NAME'..."
|
|
||||||
ORG_RESPONSE=$(api_post "/api/organizations" "{
|
|
||||||
\"name\": \"$TENANT_NAME\",
|
|
||||||
\"description\": \"Bootstrap demo tenant\"
|
|
||||||
}")
|
|
||||||
ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id')
|
|
||||||
log "Created organization: $ORG_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add users to organization
|
|
||||||
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
|
|
||||||
log "Adding platform owner to organization..."
|
|
||||||
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
|
|
||||||
api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" >/dev/null 2>&1
|
|
||||||
log "Platform owner added to org with owner role."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
|
|
||||||
log "Adding viewer user to organization..."
|
|
||||||
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1
|
|
||||||
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_VIEWER_ROLE_ID\"]}" >/dev/null 2>&1
|
|
||||||
log "Viewer user added to org with viewer role."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# PHASE 7: Configure cameleer3-server OIDC
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
SERVER_HEALTHY="no"
|
|
||||||
for i in 1 2 3; do
|
|
||||||
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
|
|
||||||
SERVER_HEALTHY="yes"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
log "Phase 7 check: SERVER_HEALTHY=$SERVER_HEALTHY, TRAD_SECRET length=${#TRAD_SECRET}"
|
|
||||||
|
|
||||||
if [ "$SERVER_HEALTHY" = "yes" ] && [ -n "$TRAD_SECRET" ]; then
|
|
||||||
log "Configuring cameleer3-server OIDC..."
|
|
||||||
|
|
||||||
# Login to server as admin
|
|
||||||
SERVER_TOKEN_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/auth/login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"username\": \"$SERVER_UI_USER\", \"password\": \"$SERVER_UI_PASS\"}")
|
|
||||||
SERVER_TOKEN=$(echo "$SERVER_TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -n "$SERVER_TOKEN" ] && [ "$SERVER_TOKEN" != "null" ]; then
|
|
||||||
# Configure OIDC
|
|
||||||
OIDC_RESPONSE=$(curl -s -X PUT "${SERVER_ENDPOINT}/api/v1/admin/oidc" \
|
|
||||||
-H "Authorization: Bearer $SERVER_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"enabled\": true,
|
|
||||||
\"issuerUri\": \"$LOGTO_PUBLIC_ENDPOINT/oidc\",
|
|
||||||
\"clientId\": \"$TRAD_ID\",
|
|
||||||
\"clientSecret\": \"$TRAD_SECRET\",
|
|
||||||
\"autoSignup\": true,
|
|
||||||
\"defaultRoles\": [\"VIEWER\"],
|
|
||||||
\"displayNameClaim\": \"name\",
|
|
||||||
\"rolesClaim\": \"roles\",
|
|
||||||
\"audience\": \"$API_RESOURCE_INDICATOR\",
|
|
||||||
\"additionalScopes\": []
|
|
||||||
}")
|
|
||||||
log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)"
|
|
||||||
log "cameleer3-server OIDC configured."
|
|
||||||
|
|
||||||
# Seed claim mapping rules (roles → server RBAC)
|
|
||||||
log "Seeding claim mapping rules..."
|
|
||||||
EXISTING_MAPPINGS=$(curl -s -H "Authorization: Bearer $SERVER_TOKEN" \
|
|
||||||
"${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" 2>/dev/null || echo "[]")
|
|
||||||
|
|
||||||
seed_claim_mapping() {
|
|
||||||
local match_value="$1"
|
|
||||||
local target="$2"
|
|
||||||
local priority="$3"
|
|
||||||
local exists=$(echo "$EXISTING_MAPPINGS" | jq -r ".[] | select(.matchValue == \"$match_value\") | .id")
|
|
||||||
if [ -n "$exists" ]; then
|
|
||||||
log " Claim mapping '$match_value' → $target exists"
|
|
||||||
else
|
|
||||||
local resp=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" \
|
|
||||||
-H "Authorization: Bearer $SERVER_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"claim\":\"roles\",\"matchType\":\"contains\",\"matchValue\":\"$match_value\",\"action\":\"assignRole\",\"target\":\"$target\",\"priority\":$priority}")
|
|
||||||
log " Created claim mapping '$match_value' → $target"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
seed_claim_mapping "server:admin" "ADMIN" 10
|
|
||||||
seed_claim_mapping "server:operator" "OPERATOR" 20
|
|
||||||
log "Claim mapping rules seeded."
|
|
||||||
else
|
|
||||||
log "WARNING: Could not login to cameleer3-server — skipping OIDC config"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "WARNING: cameleer3-server not available or no Traditional app secret — skipping OIDC config"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 7b: Configure Logto Custom JWT for access tokens
|
# PHASE 7b: Configure Logto Custom JWT for access tokens
|
||||||
@@ -644,7 +552,15 @@ CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environme
|
|||||||
if (role.name === "saas-vendor") roles.add("server:admin");
|
if (role.name === "saas-vendor") roles.add("server:admin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roles.size > 0 ? { roles: [...roles] } : {};
|
const mfaFactors = context?.user?.mfaVerificationFactors || [];
|
||||||
|
const mfaEnrolled = mfaFactors.some(f => f.type === "Totp" || f.type === "WebAuthn");
|
||||||
|
const passkeyEnrolled = mfaFactors.some(f => f.type === "WebAuthn");
|
||||||
|
const claims = {};
|
||||||
|
if (roles.size > 0) claims.roles = [...roles];
|
||||||
|
claims.mfa_enrolled = mfaEnrolled;
|
||||||
|
claims.passkey_enrolled = passkeyEnrolled;
|
||||||
|
claims.mfa_method_preference = context?.user?.customData?.mfa_method_preference || null;
|
||||||
|
return claims;
|
||||||
};'
|
};'
|
||||||
|
|
||||||
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
|
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
|
||||||
@@ -674,6 +590,38 @@ api_patch "/api/sign-in-exp" "{
|
|||||||
}"
|
}"
|
||||||
log "Sign-in branding configured."
|
log "Sign-in branding configured."
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PHASE 8c: Configure sign-in experience (sign-in only)
|
||||||
|
# ============================================================
|
||||||
|
# Registration is disabled by default. The vendor admin enables it
|
||||||
|
# via the Email Connector UI after configuring SMTP delivery.
|
||||||
|
|
||||||
|
log "Configuring sign-in experience (sign-in only, no registration)..."
|
||||||
|
api_patch "/api/sign-in-exp" '{
|
||||||
|
"signInMode": "SignIn",
|
||||||
|
"signIn": {
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"identifier": "email",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "username",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"factors": ["Totp", "WebAuthn", "BackupCode"],
|
||||||
|
"policy": "UserControlled"
|
||||||
|
}
|
||||||
|
}' >/dev/null 2>&1
|
||||||
|
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 9: Cleanup seeded apps
|
# PHASE 9: Cleanup seeded apps
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -702,25 +650,52 @@ cat > "$BOOTSTRAP_FILE" <<EOF
|
|||||||
"tradAppId": "$TRAD_ID",
|
"tradAppId": "$TRAD_ID",
|
||||||
"tradAppSecret": "$TRAD_SECRET",
|
"tradAppSecret": "$TRAD_SECRET",
|
||||||
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
||||||
"organizationId": "$ORG_ID",
|
|
||||||
"tenantName": "$TENANT_NAME",
|
|
||||||
"tenantSlug": "$TENANT_SLUG",
|
|
||||||
"bootstrapToken": "$BOOTSTRAP_TOKEN",
|
|
||||||
"platformAdminUser": "$SAAS_ADMIN_USER",
|
"platformAdminUser": "$SAAS_ADMIN_USER",
|
||||||
"tenantAdminUser": "$TENANT_ADMIN_USER",
|
|
||||||
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
|
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
|
||||||
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
chmod 644 "$BOOTSTRAP_FILE"
|
chmod 644 "$BOOTSTRAP_FILE"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Phase 12: SaaS Admin Role
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== Phase 12: SaaS Admin Role ==="
|
||||||
|
|
||||||
|
# Create saas-vendor global role with all API scopes
|
||||||
|
log "Checking for saas-vendor role..."
|
||||||
|
EXISTING_ROLES=$(api_get "/api/roles")
|
||||||
|
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
|
||||||
|
|
||||||
|
if [ -z "$VENDOR_ROLE_ID" ]; then
|
||||||
|
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
|
||||||
|
log "Creating saas-vendor role with all scopes..."
|
||||||
|
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
|
||||||
|
\"name\": \"saas-vendor\",
|
||||||
|
\"description\": \"SaaS vendor — full platform control across all tenants\",
|
||||||
|
\"type\": \"User\",
|
||||||
|
\"scopeIds\": $ALL_SCOPE_IDS
|
||||||
|
}")
|
||||||
|
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
|
||||||
|
log "Created saas-vendor role: $VENDOR_ROLE_ID"
|
||||||
|
else
|
||||||
|
log "saas-vendor role exists: $VENDOR_ROLE_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Assign vendor role to admin user
|
||||||
|
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ] && [ -n "$ADMIN_USER_ID" ]; then
|
||||||
|
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
|
||||||
|
log "Assigned saas-vendor role to admin user."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "SaaS admin role configured."
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "=== Bootstrap complete! ==="
|
log "=== Bootstrap complete! ==="
|
||||||
# dev only — remove credential logging in production
|
# dev only — remove credential logging in production
|
||||||
log " Platform Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS (org role: owner)"
|
|
||||||
log " Viewer: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS (org role: viewer)"
|
|
||||||
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
|
|
||||||
log " Organization: $ORG_ID"
|
|
||||||
log " SPA Client ID: $SPA_ID"
|
log " SPA Client ID: $SPA_ID"
|
||||||
log ""
|
log ""
|
||||||
log " To add SaaS Vendor role (hosted only): run docker/vendor-seed.sh"
|
log " No tenants created — use the admin console to create tenants."
|
||||||
|
log ""
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Agent JAR is copied during CI build from Gitea Maven registry
|
# Agent JAR and log appender JAR are copied during CI build from Gitea Maven registry
|
||||||
# ARG AGENT_JAR=cameleer3-agent-1.0-SNAPSHOT-shaded.jar
|
|
||||||
COPY agent.jar /app/agent.jar
|
COPY agent.jar /app/agent.jar
|
||||||
|
COPY cameleer-log-appender.jar /app/cameleer-log-appender.jar
|
||||||
|
|
||||||
ENTRYPOINT exec java \
|
ENTRYPOINT exec java \
|
||||||
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
|
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
root-redirect:
|
|
||||||
rule: "Path(`/`)"
|
|
||||||
priority: 100
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
tls: {}
|
|
||||||
middlewares:
|
|
||||||
- root-to-platform
|
|
||||||
service: saas@docker
|
|
||||||
middlewares:
|
|
||||||
root-to-platform:
|
|
||||||
redirectRegex:
|
|
||||||
regex: "^(https?://[^/]+)/?$"
|
|
||||||
replacement: "${1}/platform/"
|
|
||||||
permanent: false
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Cameleer SaaS — Vendor Seed Script
|
|
||||||
# Creates the saas-vendor global role and vendor user.
|
|
||||||
# Run ONCE on the hosted SaaS environment AFTER standard bootstrap.
|
|
||||||
# NOT part of docker-compose.yml — invoked manually or by CI.
|
|
||||||
|
|
||||||
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
|
|
||||||
MGMT_API_RESOURCE="https://default.logto.app/api"
|
|
||||||
API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
|
||||||
PG_HOST="${PG_HOST:-postgres}"
|
|
||||||
PG_USER="${PG_USER:-cameleer}"
|
|
||||||
PG_DB_LOGTO="logto"
|
|
||||||
|
|
||||||
# Vendor credentials (override via env vars)
|
|
||||||
VENDOR_USER="${VENDOR_USER:-vendor}"
|
|
||||||
VENDOR_PASS="${VENDOR_PASS:-vendor}"
|
|
||||||
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
|
|
||||||
|
|
||||||
log() { echo "[vendor-seed] $1"; }
|
|
||||||
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
|
|
||||||
|
|
||||||
# Install jq + curl
|
|
||||||
apk add --no-cache jq curl >/dev/null 2>&1
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Get Management API token
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
log "Reading M2M credentials from bootstrap file..."
|
|
||||||
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
|
||||||
if [ ! -f "$BOOTSTRAP_FILE" ]; then
|
|
||||||
log "ERROR: Bootstrap file not found at $BOOTSTRAP_FILE — run standard bootstrap first"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
M2M_ID=$(jq -r '.m2mClientId' "$BOOTSTRAP_FILE")
|
|
||||||
M2M_SECRET=$(jq -r '.m2mClientSecret' "$BOOTSTRAP_FILE")
|
|
||||||
|
|
||||||
if [ -z "$M2M_ID" ] || [ "$M2M_ID" = "null" ] || [ -z "$M2M_SECRET" ] || [ "$M2M_SECRET" = "null" ]; then
|
|
||||||
log "ERROR: M2M credentials not found in bootstrap file"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Getting Management API token..."
|
|
||||||
TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "grant_type=client_credentials&client_id=${M2M_ID}&client_secret=${M2M_SECRET}&resource=${MGMT_API_RESOURCE}&scope=all")
|
|
||||||
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
|
||||||
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
|
|
||||||
log "Got Management API token."
|
|
||||||
|
|
||||||
api_get() { curl -s -H "Authorization: Bearer $TOKEN" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"; }
|
|
||||||
api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Create saas-vendor global role
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
log "Checking for saas-vendor role..."
|
|
||||||
EXISTING_ROLES=$(api_get "/api/roles")
|
|
||||||
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
|
|
||||||
|
|
||||||
if [ -n "$VENDOR_ROLE_ID" ]; then
|
|
||||||
log "saas-vendor role exists: $VENDOR_ROLE_ID"
|
|
||||||
else
|
|
||||||
# Collect all API resource scope IDs
|
|
||||||
EXISTING_RESOURCES=$(api_get "/api/resources")
|
|
||||||
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
|
|
||||||
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
|
|
||||||
|
|
||||||
log "Creating saas-vendor role with all scopes..."
|
|
||||||
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
|
|
||||||
\"name\": \"saas-vendor\",
|
|
||||||
\"description\": \"SaaS vendor — full platform control across all tenants\",
|
|
||||||
\"type\": \"User\",
|
|
||||||
\"scopeIds\": $ALL_SCOPE_IDS
|
|
||||||
}")
|
|
||||||
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
|
|
||||||
log "Created saas-vendor role: $VENDOR_ROLE_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Create vendor user
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
log "Checking for vendor user '$VENDOR_USER'..."
|
|
||||||
VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id")
|
|
||||||
|
|
||||||
if [ -n "$VENDOR_USER_ID" ]; then
|
|
||||||
log "Vendor user exists: $VENDOR_USER_ID"
|
|
||||||
else
|
|
||||||
log "Creating vendor user '$VENDOR_USER'..."
|
|
||||||
VENDOR_RESPONSE=$(api_post "/api/users" "{
|
|
||||||
\"username\": \"$VENDOR_USER\",
|
|
||||||
\"password\": \"$VENDOR_PASS\",
|
|
||||||
\"name\": \"$VENDOR_NAME\"
|
|
||||||
}")
|
|
||||||
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
|
|
||||||
log "Created vendor user: $VENDOR_USER_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Assign saas-vendor role
|
|
||||||
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ]; then
|
|
||||||
api_post "/api/users/$VENDOR_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
|
|
||||||
log "Assigned saas-vendor role."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Add vendor to all existing organizations with owner role
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
log "Adding vendor to all organizations..."
|
|
||||||
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
|
|
||||||
ORGS=$(api_get "/api/organizations")
|
|
||||||
ORG_COUNT=$(echo "$ORGS" | jq 'length')
|
|
||||||
|
|
||||||
for i in $(seq 0 $((ORG_COUNT - 1))); do
|
|
||||||
ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
|
|
||||||
ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
|
|
||||||
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$VENDOR_USER_ID\"]}" >/dev/null 2>&1
|
|
||||||
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
|
|
||||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
|
|
||||||
"${LOGTO_ENDPOINT}/api/organizations/$ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
log " Added to org '$ORG_NAME' ($ORG_ID) with owner role."
|
|
||||||
done
|
|
||||||
|
|
||||||
log ""
|
|
||||||
log "=== Vendor seed complete! ==="
|
|
||||||
log " Vendor user: $VENDOR_USER / $VENDOR_PASS"
|
|
||||||
log " Role: saas-vendor (global) + owner (in all orgs)"
|
|
||||||
log " This user has platform:admin scope and cross-tenant access."
|
|
||||||
@@ -15,12 +15,12 @@ infrastructure themselves.
|
|||||||
|
|
||||||
The system comprises three components:
|
The system comprises three components:
|
||||||
|
|
||||||
**Cameleer Agent** (`cameleer3` repo) -- A Java agent using ByteBuddy for
|
**Cameleer Agent** (`cameleer` repo) -- A Java agent using ByteBuddy for
|
||||||
zero-code bytecode instrumentation. Captures route executions, processor traces,
|
zero-code bytecode instrumentation. Captures route executions, processor traces,
|
||||||
payloads, metrics, and route graph topology. Deployed as a `-javaagent` JAR
|
payloads, metrics, and route graph topology. Deployed as a `-javaagent` JAR
|
||||||
alongside the customer's application.
|
alongside the customer's application.
|
||||||
|
|
||||||
**Cameleer Server** (`cameleer3-server` repo) -- A Spring Boot observability
|
**Cameleer Server** (`cameleer-server` repo) -- A Spring Boot observability
|
||||||
backend. Receives telemetry from agents via HTTP, pushes configuration and
|
backend. Receives telemetry from agents via HTTP, pushes configuration and
|
||||||
commands to agents via SSE. Stores data in PostgreSQL and ClickHouse. Provides
|
commands to agents via SSE. Stores data in PostgreSQL and ClickHouse. Provides
|
||||||
a React SPA dashboard for direct observability access. JWT auth with Ed25519
|
a React SPA dashboard for direct observability access. JWT auth with Ed25519
|
||||||
@@ -50,7 +50,7 @@ logging. Serves a React SPA that wraps the full user experience.
|
|||||||
| | /interaction) |
|
| | /interaction) |
|
||||||
v v v v
|
v v v v
|
||||||
+--------------+ +--------------+ +-----------+ +------------------+
|
+--------------+ +--------------+ +-----------+ +------------------+
|
||||||
| cameleer-saas| | cameleer-saas| | Logto | | cameleer3-server |
|
| cameleer-saas| | cameleer-saas| | Logto | | cameleer-server |
|
||||||
| (API) | | (SPA) | | | | |
|
| (API) | | (SPA) | | | | |
|
||||||
| :8080 | | :8080 | | :3001 | | :8081 |
|
| :8080 | | :8080 | | :3001 | | :8081 |
|
||||||
+--------------+ +--------------+ +-----------+ +------------------+
|
+--------------+ +--------------+ +-----------+ +------------------+
|
||||||
@@ -80,14 +80,14 @@ logging. Serves a React SPA that wraps the full user experience.
|
|||||||
| logto | `ghcr.io/logto-io/logto:latest` | 3001 | cameleer | OIDC identity provider |
|
| logto | `ghcr.io/logto-io/logto:latest` | 3001 | cameleer | OIDC identity provider |
|
||||||
| logto-bootstrap | `postgres:16-alpine` (ephemeral) | -- | cameleer | One-shot bootstrap script |
|
| logto-bootstrap | `postgres:16-alpine` (ephemeral) | -- | cameleer | One-shot bootstrap script |
|
||||||
| cameleer-saas | `gitea.siegeln.net/cameleer/cameleer-saas` | 8080 | cameleer | SaaS API + SPA serving |
|
| cameleer-saas | `gitea.siegeln.net/cameleer/cameleer-saas` | 8080 | cameleer | SaaS API + SPA serving |
|
||||||
| cameleer3-server | `gitea.siegeln.net/cameleer/cameleer3-server`| 8081 | cameleer | Observability backend |
|
| cameleer-server | `gitea.siegeln.net/cameleer/cameleer-server`| 8081 | cameleer | Observability backend |
|
||||||
| clickhouse | `clickhouse/clickhouse-server:latest` | 8123 | cameleer | Time-series telemetry storage |
|
| clickhouse | `clickhouse/clickhouse-server:latest` | 8123 | cameleer | Time-series telemetry storage |
|
||||||
|
|
||||||
### Docker Network
|
### Docker Network
|
||||||
|
|
||||||
All services share a single Docker bridge network named `cameleer`. Customer app
|
All services share a single Docker bridge network named `cameleer`. Customer app
|
||||||
containers are also attached to this network so agents can reach the
|
containers are also attached to this network so agents can reach the
|
||||||
cameleer3-server.
|
cameleer-server.
|
||||||
|
|
||||||
### Volumes
|
### Volumes
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ The shared PostgreSQL instance hosts three databases:
|
|||||||
|
|
||||||
- `cameleer_saas` -- SaaS platform tables (tenants, environments, apps, etc.)
|
- `cameleer_saas` -- SaaS platform tables (tenants, environments, apps, etc.)
|
||||||
- `logto` -- Logto identity provider data
|
- `logto` -- Logto identity provider data
|
||||||
- `cameleer3` -- cameleer3-server operational data
|
- `cameleer` -- cameleer-server operational data
|
||||||
|
|
||||||
The `docker/init-databases.sh` init script creates all three during first start.
|
The `docker/init-databases.sh` init script creates all three during first start.
|
||||||
|
|
||||||
@@ -128,9 +128,9 @@ The `docker/init-databases.sh` init script creates all three during first start.
|
|||||||
|--------------------|-----------------|------------------|----------------------|--------------------------------|
|
|--------------------|-----------------|------------------|----------------------|--------------------------------|
|
||||||
| Logto user JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS UI users, server users |
|
| Logto user JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS UI users, server users |
|
||||||
| Logto M2M JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS platform -> server calls |
|
| Logto M2M JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS platform -> server calls |
|
||||||
| Server internal JWT| cameleer3-server| HS256 (symmetric) | Issuing server only | Agents (after registration) |
|
| Server internal JWT| cameleer-server| HS256 (symmetric) | Issuing server only | Agents (after registration) |
|
||||||
| API key (opaque) | SaaS platform | N/A (SHA-256 hash)| cameleer3-server | Agent initial registration |
|
| API key (opaque) | SaaS platform | N/A (SHA-256 hash)| cameleer-server | Agent initial registration |
|
||||||
| Ed25519 signature | cameleer3-server| EdDSA | Agent | Server -> agent command signing|
|
| Ed25519 signature | cameleer-server| EdDSA | Agent | Server -> agent command signing|
|
||||||
|
|
||||||
### 3.3 Scope Model
|
### 3.3 Scope Model
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ the bootstrap script (`docker/logto-bootstrap.sh`):
|
|||||||
4. `organization_id` claim in JWT resolves to internal tenant ID via
|
4. `organization_id` claim in JWT resolves to internal tenant ID via
|
||||||
`TenantIsolationInterceptor`.
|
`TenantIsolationInterceptor`.
|
||||||
|
|
||||||
**SaaS platform -> cameleer3-server API (M2M):**
|
**SaaS platform -> cameleer-server API (M2M):**
|
||||||
|
|
||||||
1. SaaS platform obtains Logto M2M token (`client_credentials` grant) via
|
1. SaaS platform obtains Logto M2M token (`client_credentials` grant) via
|
||||||
`LogtoManagementClient`.
|
`LogtoManagementClient`.
|
||||||
@@ -191,9 +191,9 @@ the bootstrap script (`docker/logto-bootstrap.sh`):
|
|||||||
3. Server validates via Logto JWKS (OIDC resource server support).
|
3. Server validates via Logto JWKS (OIDC resource server support).
|
||||||
4. Server grants ADMIN role to valid M2M tokens.
|
4. Server grants ADMIN role to valid M2M tokens.
|
||||||
|
|
||||||
**Agent -> cameleer3-server:**
|
**Agent -> cameleer-server:**
|
||||||
|
|
||||||
1. Agent reads `CAMELEER_AUTH_TOKEN` environment variable (API key).
|
1. Agent reads `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` environment variable (API key).
|
||||||
2. Calls `POST /api/v1/agents/register` with the key as Bearer token.
|
2. Calls `POST /api/v1/agents/register` with the key as Bearer token.
|
||||||
3. Server validates via `BootstrapTokenValidator` (constant-time comparison).
|
3. Server validates via `BootstrapTokenValidator` (constant-time comparison).
|
||||||
4. Server issues internal HMAC JWT (access + refresh) + Ed25519 public key.
|
4. Server issues internal HMAC JWT (access + refresh) + Ed25519 public key.
|
||||||
@@ -458,9 +458,9 @@ Defined in `AuditAction.java`:
|
|||||||
|
|
||||||
### 5.1 Server-Per-Tenant
|
### 5.1 Server-Per-Tenant
|
||||||
|
|
||||||
Each tenant gets a dedicated cameleer3-server instance. The SaaS platform
|
Each tenant gets a dedicated cameleer-server instance. The SaaS platform
|
||||||
provisions and manages these servers. In the current Docker Compose topology, a
|
provisions and manages these servers. In the current Docker Compose topology, a
|
||||||
single shared cameleer3-server is used for the default tenant. Production
|
single shared cameleer-server is used for the default tenant. Production
|
||||||
deployments will run per-tenant servers as separate containers or K8s pods.
|
deployments will run per-tenant servers as separate containers or K8s pods.
|
||||||
|
|
||||||
### 5.2 Customer App Deployment Flow
|
### 5.2 Customer App Deployment Flow
|
||||||
@@ -493,9 +493,9 @@ The deployment lifecycle is managed by `DeploymentService`:
|
|||||||
|
|
||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
|-----------------------------|----------------------------------------|
|
|-----------------------------|----------------------------------------|
|
||||||
| `CAMELEER_AUTH_TOKEN` | API key for agent registration |
|
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` | API key for agent registration |
|
||||||
| `CAMELEER_EXPORT_TYPE` | `HTTP` |
|
| `CAMELEER_EXPORT_TYPE` | `HTTP` |
|
||||||
| `CAMELEER_SERVER_URL` | cameleer3-server internal URL |
|
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | cameleer-server internal URL |
|
||||||
| `CAMELEER_APPLICATION_ID` | App slug |
|
| `CAMELEER_APPLICATION_ID` | App slug |
|
||||||
| `CAMELEER_ENVIRONMENT_ID` | Environment slug |
|
| `CAMELEER_ENVIRONMENT_ID` | Environment slug |
|
||||||
| `CAMELEER_DISPLAY_NAME` | `{tenant}-{env}-{app}` |
|
| `CAMELEER_DISPLAY_NAME` | `{tenant}-{env}-{app}` |
|
||||||
@@ -524,14 +524,14 @@ Configured via `RuntimeConfig`:
|
|||||||
## 6. Agent-Server Protocol
|
## 6. Agent-Server Protocol
|
||||||
|
|
||||||
The agent-server protocol is defined in full in
|
The agent-server protocol is defined in full in
|
||||||
`cameleer3/cameleer3-common/PROTOCOL.md`. This section summarizes the key
|
`cameleer/cameleer-common/PROTOCOL.md`. This section summarizes the key
|
||||||
aspects relevant to the SaaS platform.
|
aspects relevant to the SaaS platform.
|
||||||
|
|
||||||
### 6.1 Agent Registration
|
### 6.1 Agent Registration
|
||||||
|
|
||||||
1. Agent starts with `CAMELEER_AUTH_TOKEN` environment variable (an API key
|
1. Agent starts with `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` environment variable (an API key
|
||||||
generated by the SaaS platform, prefixed with `cmk_`).
|
generated by the SaaS platform, prefixed with `cmk_`).
|
||||||
2. Agent calls `POST /api/v1/agents/register` on the cameleer3-server with the
|
2. Agent calls `POST /api/v1/agents/register` on the cameleer-server with the
|
||||||
API key as a Bearer token.
|
API key as a Bearer token.
|
||||||
3. Server validates the key and returns:
|
3. Server validates the key and returns:
|
||||||
- HMAC JWT access token (short-lived, ~1 hour)
|
- HMAC JWT access token (short-lived, ~1 hour)
|
||||||
@@ -744,7 +744,7 @@ leaks regardless of whether the request succeeded or failed.
|
|||||||
|----------------------|-------------|------------------------------------|
|
|----------------------|-------------|------------------------------------|
|
||||||
| Logto access token | ~1 hour | Configured in Logto, refreshed by SDK |
|
| Logto access token | ~1 hour | Configured in Logto, refreshed by SDK |
|
||||||
| Logto refresh token | ~14 days | Used by `@logto/react` for silent refresh |
|
| Logto refresh token | ~14 days | Used by `@logto/react` for silent refresh |
|
||||||
| Server agent JWT | ~1 hour | cameleer3-server `CAMELEER_JWT_SECRET` |
|
| Server agent JWT | ~1 hour | cameleer-server `CAMELEER_JWT_SECRET` |
|
||||||
| Server refresh token | ~7 days | Agent re-registers when expired |
|
| Server refresh token | ~7 days | Agent re-registers when expired |
|
||||||
|
|
||||||
### 8.4 Audit Logging
|
### 8.4 Audit Logging
|
||||||
@@ -858,51 +858,61 @@ state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|------------------------------|----------------------------------------------|----------------------------------|
|
|------------------------------|----------------------------------------------|----------------------------------|
|
||||||
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer_saas` | PostgreSQL JDBC URL |
|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://cameleer-postgres:5432/cameleer_saas` | PostgreSQL JDBC URL |
|
||||||
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
|
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
|
||||||
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
|
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
|
||||||
|
|
||||||
**Logto / OIDC:**
|
**Identity / OIDC:**
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---------------------------|------------|--------------------------------------------|
|
|---------------------------|------------|--------------------------------------------|
|
||||||
| `LOGTO_ENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
|
| `CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
|
||||||
| `LOGTO_PUBLIC_ENDPOINT` | (empty) | Logto public URL (browser-accessible) |
|
| `CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT` | (empty) | Logto public URL (browser-accessible) |
|
||||||
| `LOGTO_ISSUER_URI` | (empty) | OIDC issuer URI for JWT validation |
|
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTID` | (empty) | M2M app client ID (from bootstrap) |
|
||||||
| `LOGTO_JWK_SET_URI` | (empty) | JWKS endpoint for JWT signature validation |
|
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET` | (empty) | M2M app client secret (from bootstrap) |
|
||||||
| `LOGTO_M2M_CLIENT_ID` | (empty) | M2M app client ID (from bootstrap) |
|
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
|
||||||
| `LOGTO_M2M_CLIENT_SECRET` | (empty) | M2M app client secret (from bootstrap) |
|
|
||||||
| `LOGTO_SPA_CLIENT_ID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
|
|
||||||
|
|
||||||
**Runtime / Deployment:**
|
**Provisioning** (`cameleer.saas.provisioning.*` / `CAMELEER_SAAS_PROVISIONING_*`):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|-----------------------------------|------------------------------------|----------------------------------|
|
|-----------------------------------|------------------------------------|----------------------------------|
|
||||||
| `CAMELEER3_SERVER_ENDPOINT` | `http://cameleer3-server:8081` | cameleer3-server internal URL |
|
| `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE` | `gitea.siegeln.net/cameleer/cameleer-server:latest` | Docker image for per-tenant server |
|
||||||
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | JAR upload storage directory |
|
| `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE` | `gitea.siegeln.net/cameleer/cameleer-server-ui:latest` | Docker image for per-tenant UI |
|
||||||
| `CAMELEER_RUNTIME_BASE_IMAGE` | `cameleer-runtime-base:latest` | Base Docker image for app builds |
|
| `CAMELEER_SAAS_PROVISIONING_NETWORKNAME` | `cameleer-saas_cameleer` | Shared services Docker network |
|
||||||
| `CAMELEER_DOCKER_NETWORK` | `cameleer` | Docker network for containers |
|
| `CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK` | `cameleer-traefik` | Traefik Docker network |
|
||||||
| `CAMELEER_CONTAINER_MEMORY_LIMIT`| `512m` | Per-container memory limit |
|
| `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` | `localhost` | Public hostname (same as infrastructure `PUBLIC_HOST`) |
|
||||||
| `CAMELEER_CONTAINER_CPU_SHARES` | `512` | Per-container CPU shares |
|
| `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` | `https` | Public protocol (same as infrastructure `PUBLIC_PROTOCOL`) |
|
||||||
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
|
| `CAMELEER_SAAS_PROVISIONING_DATASOURCEURL` | `jdbc:postgresql://cameleer-postgres:5432/cameleer` | PostgreSQL URL passed to tenant servers |
|
||||||
| `CLICKHOUSE_ENABLED` | `true` | Enable ClickHouse integration |
|
| `CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL` | `jdbc:clickhouse://cameleer-clickhouse:8123/cameleer` | ClickHouse URL passed to tenant servers |
|
||||||
| `CLICKHOUSE_USERNAME` | `default` | ClickHouse user |
|
|
||||||
| `CLICKHOUSE_PASSWORD` | (empty) | ClickHouse password |
|
|
||||||
| `DOMAIN` | `localhost` | Base domain for Traefik routing |
|
|
||||||
|
|
||||||
### 10.2 cameleer3-server
|
### 10.2 cameleer-server (per-tenant)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
Env vars injected into provisioned per-tenant server containers by `DockerTenantProvisioner`. All server properties use the `cameleer.server.*` prefix (env vars: `CAMELEER_SERVER_*`).
|
||||||
|
|
||||||
|
| Variable | Default / Value | Description |
|
||||||
|------------------------------|----------------------------------------------|----------------------------------|
|
|------------------------------|----------------------------------------------|----------------------------------|
|
||||||
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer3` | PostgreSQL JDBC URL |
|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://cameleer-postgres:5432/cameleer` | PostgreSQL JDBC URL |
|
||||||
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
|
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
|
||||||
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
|
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
|
||||||
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
|
| `CAMELEER_SERVER_CLICKHOUSE_URL` | `jdbc:clickhouse://cameleer-clickhouse:8123/cameleer` | ClickHouse JDBC URL |
|
||||||
| `CAMELEER_AUTH_TOKEN` | `default-bootstrap-token` | Agent bootstrap token |
|
| `CAMELEER_SERVER_TENANT_ID` | *(tenant slug)* | Tenant identifier for data isolation |
|
||||||
|
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` | *(generated)* | Agent bootstrap token |
|
||||||
|
| `CAMELEER_SERVER_SECURITY_JWTSECRET` | *(generated, must be non-empty)* | JWT signing secret |
|
||||||
|
| `CAMELEER_SERVER_SECURITY_OIDC_ISSUERURI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | OIDC issuer for M2M tokens |
|
||||||
|
| `CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI` | `http://cameleer-logto:3001/oidc/jwks` | Docker-internal JWK fetch |
|
||||||
|
| `CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE` | `https://api.cameleer.local` | JWT audience validation |
|
||||||
|
| `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | CORS for browser requests |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | `http://cameleer-server-{slug}:8081` | Per-tenant server URL |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` | `path` or `subdomain` routing |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH` | `/data/jars` | JAR file storage directory |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` | `cameleer-tenant-{slug}` | Primary network for app containers |
|
||||||
|
| `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` | `cameleer-jars-{slug}` | Docker volume for JAR sharing |
|
||||||
| `CAMELEER_JWT_SECRET` | `cameleer-dev-jwt-secret-...` | HMAC secret for internal JWTs |
|
| `CAMELEER_JWT_SECRET` | `cameleer-dev-jwt-secret-...` | HMAC secret for internal JWTs |
|
||||||
| `CAMELEER_TENANT_ID` | `default` | Tenant slug for data isolation |
|
| `CAMELEER_SERVER_TENANT_ID` | `default` | Tenant slug for data isolation |
|
||||||
| `CAMELEER_OIDC_ISSUER_URI` | (empty) | Logto issuer for M2M token validation |
|
| `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` | (empty) | Logto issuer for M2M token validation |
|
||||||
| `CAMELEER_OIDC_AUDIENCE` | (empty) | Expected JWT audience |
|
| `CAMELEER_SERVER_SECURITY_OIDCAUDIENCE` | (empty) | Expected JWT audience |
|
||||||
|
|
||||||
### 10.3 logto
|
### 10.3 logto
|
||||||
|
|
||||||
@@ -927,7 +937,7 @@ state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
|
|||||||
| `SAAS_ADMIN_PASS` | `admin` | Platform admin password |
|
| `SAAS_ADMIN_PASS` | `admin` | Platform admin password |
|
||||||
| `TENANT_ADMIN_USER` | `camel` | Default tenant admin username |
|
| `TENANT_ADMIN_USER` | `camel` | Default tenant admin username |
|
||||||
| `TENANT_ADMIN_PASS` | `camel` | Default tenant admin password |
|
| `TENANT_ADMIN_PASS` | `camel` | Default tenant admin password |
|
||||||
| `CAMELEER_AUTH_TOKEN`| `default-bootstrap-token` | Agent bootstrap token |
|
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN`| `default-bootstrap-token` | Agent bootstrap token |
|
||||||
|
|
||||||
### 10.6 Bootstrap Output
|
### 10.6 Bootstrap Output
|
||||||
|
|
||||||
@@ -947,7 +957,7 @@ The bootstrap script writes `/data/logto-bootstrap.json` containing:
|
|||||||
"bootstrapToken": "<from env>",
|
"bootstrapToken": "<from env>",
|
||||||
"platformAdminUser": "<from env>",
|
"platformAdminUser": "<from env>",
|
||||||
"tenantAdminUser": "<from env>",
|
"tenantAdminUser": "<from env>",
|
||||||
"oidcIssuerUri": "http://logto:3001/oidc",
|
"oidcIssuerUri": "http://cameleer-logto:3001/oidc",
|
||||||
"oidcAudience": "https://api.cameleer.local"
|
"oidcAudience": "https://api.cameleer.local"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
|||||||
**PRD Sections:** 6 (Tenant Provisioning), 11 (Networking & Tenant Isolation)
|
**PRD Sections:** 6 (Tenant Provisioning), 11 (Networking & Tenant Isolation)
|
||||||
**Gitea Epics:** #3 (Tenant Provisioning), #8 (Networking)
|
**Gitea Epics:** #3 (Tenant Provisioning), #8 (Networking)
|
||||||
**Depends on:** Phase 2
|
**Depends on:** Phase 2
|
||||||
**Produces:** Automated tenant provisioning pipeline. Signup creates tenant → Flux HelmRelease generated → namespace provisioned → cameleer3-server deployed → PostgreSQL schema + OpenSearch index created → tenant ACTIVE. NetworkPolicies enforced.
|
**Produces:** Automated tenant provisioning pipeline. Signup creates tenant → Flux HelmRelease generated → namespace provisioned → cameleer-server deployed → PostgreSQL schema + OpenSearch index created → tenant ACTIVE. NetworkPolicies enforced.
|
||||||
|
|
||||||
**Key deliverables:**
|
**Key deliverables:**
|
||||||
- Provisioning state machine (idempotent, retryable)
|
- Provisioning state machine (idempotent, retryable)
|
||||||
@@ -91,7 +91,7 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
|||||||
- Readiness checking (poll tenant server health)
|
- Readiness checking (poll tenant server health)
|
||||||
- Tenant lifecycle operations (suspend, reactivate, delete)
|
- Tenant lifecycle operations (suspend, reactivate, delete)
|
||||||
- K8s NetworkPolicy templates (default deny + allow rules)
|
- K8s NetworkPolicy templates (default deny + allow rules)
|
||||||
- Helm chart for cameleer3-server tenant deployment
|
- Helm chart for cameleer-server tenant deployment
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,11 +143,11 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
|||||||
**PRD Sections:** 8 (Observability Integration)
|
**PRD Sections:** 8 (Observability Integration)
|
||||||
**Gitea Epics:** #6 (Observability Integration), #13 (Exchange Replay — gating only)
|
**Gitea Epics:** #6 (Observability Integration), #13 (Exchange Replay — gating only)
|
||||||
**Depends on:** Phase 3 (server already deployed per tenant), Phase 2 (license for feature gating)
|
**Depends on:** Phase 3 (server already deployed per tenant), Phase 2 (license for feature gating)
|
||||||
**Produces:** Tenants see their cameleer3-server UI embedded in the SaaS shell. API gateway routes to tenant server. MOAT features gated by license tier.
|
**Produces:** Tenants see their cameleer-server UI embedded in the SaaS shell. API gateway routes to tenant server. MOAT features gated by license tier.
|
||||||
|
|
||||||
**Key deliverables:**
|
**Key deliverables:**
|
||||||
- Ingress routing rules: `/t/{tenant}/api/*` → tenant's cameleer3-server
|
- Ingress routing rules: `/t/{tenant}/api/*` → tenant's cameleer-server
|
||||||
- cameleer3-server "managed mode" configuration (trust SaaS JWT, report metrics)
|
- cameleer-server "managed mode" configuration (trust SaaS JWT, report metrics)
|
||||||
- Bootstrap token generation API
|
- Bootstrap token generation API
|
||||||
- MOAT feature gating via license (topology=all, lineage=limited/full, correlation=mid+, debugger=high+, replay=high+)
|
- MOAT feature gating via license (topology=all, lineage=limited/full, correlation=mid+, debugger=high+, replay=high+)
|
||||||
- Server UI embedding approach (iframe or reverse proxy with path rewriting)
|
- Server UI embedding approach (iframe or reverse proxy with path rewriting)
|
||||||
@@ -211,7 +211,7 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
|||||||
- SaaS shell (navigation, tenant switcher, user menu)
|
- SaaS shell (navigation, tenant switcher, user menu)
|
||||||
- Dashboard (platform overview)
|
- Dashboard (platform overview)
|
||||||
- Apps list + App deployment page (upload, config, secrets, status, logs, versions)
|
- Apps list + App deployment page (upload, config, secrets, status, logs, versions)
|
||||||
- Observability section (embedded cameleer3-server UI)
|
- Observability section (embedded cameleer-server UI)
|
||||||
- Team management pages
|
- Team management pages
|
||||||
- Settings pages (tenant config, SSO/OIDC, vault connections)
|
- Settings pages (tenant config, SSO/OIDC, vault connections)
|
||||||
- Billing pages (usage, invoices, plan management)
|
- Billing pages (usage, invoices, plan management)
|
||||||
|
|||||||
@@ -2006,7 +2006,7 @@ available throughout request lifecycle."
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java`
|
- Create: `src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java`
|
||||||
|
|
||||||
This endpoint is called by Traefik's ForwardAuth middleware to validate requests routed to non-platform services (e.g., cameleer3-server). It validates the JWT, resolves the tenant, and returns tenant context headers.
|
This endpoint is called by Traefik's ForwardAuth middleware to validate requests routed to non-platform services (e.g., cameleer-server). It validates the JWT, resolves the tenant, and returns tenant context headers.
|
||||||
|
|
||||||
- [ ] **Step 1: Create ForwardAuthController**
|
- [ ] **Step 1: Create ForwardAuthController**
|
||||||
|
|
||||||
@@ -2455,8 +2455,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
cameleer3-server:
|
cameleer-server:
|
||||||
image: ${CAMELEER3_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:${VERSION:-latest}
|
image: ${CAMELEER_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -2539,9 +2539,9 @@ git add docker-compose.yml docker-compose.dev.yml traefik.yml docker/init-databa
|
|||||||
git commit -m "feat: add Docker Compose production stack with Traefik + Logto
|
git commit -m "feat: add Docker Compose production stack with Traefik + Logto
|
||||||
|
|
||||||
7-container stack: Traefik (reverse proxy), PostgreSQL (shared),
|
7-container stack: Traefik (reverse proxy), PostgreSQL (shared),
|
||||||
Logto (identity), cameleer-saas (control plane), cameleer3-server
|
Logto (identity), cameleer-saas (control plane), cameleer-server
|
||||||
(observability), ClickHouse (traces). ForwardAuth middleware for
|
(observability), ClickHouse (traces). ForwardAuth middleware for
|
||||||
tenant-aware routing to cameleer3-server."
|
tenant-aware routing to cameleer-server."
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Customers can upload a Camel JAR, the platform builds a container image with cameleer3 agent auto-injected, and deploys it to a logical environment with full lifecycle management.
|
**Goal:** Customers can upload a Camel JAR, the platform builds a container image with cameleer agent auto-injected, and deploys it to a logical environment with full lifecycle management.
|
||||||
|
|
||||||
**Architecture:** Environment → App → Deployment entity hierarchy. `RuntimeOrchestrator` interface with `DockerRuntimeOrchestrator` (docker-java) implementation. Async deployment pipeline with status polling. Container logs streamed to ClickHouse. Pre-built `cameleer-runtime-base` image for fast (~1-3s) customer image builds.
|
**Architecture:** Environment → App → Deployment entity hierarchy. `RuntimeOrchestrator` interface with `DockerRuntimeOrchestrator` (docker-java) implementation. Async deployment pipeline with status polling. Container logs streamed to ClickHouse. Pre-built `cameleer-runtime-base` image for fast (~1-3s) customer image builds.
|
||||||
|
|
||||||
@@ -164,8 +164,8 @@ public class RuntimeConfig {
|
|||||||
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
|
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
|
||||||
private String bootstrapToken;
|
private String bootstrapToken;
|
||||||
|
|
||||||
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
@Value("${cameleer.runtime.cameleer-server-endpoint:http://cameleer-server:8081}")
|
||||||
private String cameleer3ServerEndpoint;
|
private String cameleerServerEndpoint;
|
||||||
|
|
||||||
public long getMaxJarSize() { return maxJarSize; }
|
public long getMaxJarSize() { return maxJarSize; }
|
||||||
public String getJarStoragePath() { return jarStoragePath; }
|
public String getJarStoragePath() { return jarStoragePath; }
|
||||||
@@ -177,7 +177,7 @@ public class RuntimeConfig {
|
|||||||
public String getContainerMemoryLimit() { return containerMemoryLimit; }
|
public String getContainerMemoryLimit() { return containerMemoryLimit; }
|
||||||
public int getContainerCpuShares() { return containerCpuShares; }
|
public int getContainerCpuShares() { return containerCpuShares; }
|
||||||
public String getBootstrapToken() { return bootstrapToken; }
|
public String getBootstrapToken() { return bootstrapToken; }
|
||||||
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
public String getCameleerServerEndpoint() { return cameleerServerEndpoint; }
|
||||||
|
|
||||||
public long parseMemoryLimitBytes() {
|
public long parseMemoryLimitBytes() {
|
||||||
var limit = containerMemoryLimit.trim().toLowerCase();
|
var limit = containerMemoryLimit.trim().toLowerCase();
|
||||||
@@ -270,7 +270,7 @@ Append to the existing `cameleer:` section in `src/main/resources/application.ym
|
|||||||
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
|
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
|
||||||
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||||||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||||||
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
cameleer-server-endpoint: ${CAMELEER_SERVER_ENDPOINT:http://cameleer-server:8081}
|
||||||
clickhouse:
|
clickhouse:
|
||||||
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
||||||
```
|
```
|
||||||
@@ -2788,7 +2788,7 @@ public class DeploymentService {
|
|||||||
var envVars = Map.of(
|
var envVars = Map.of(
|
||||||
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
|
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
|
||||||
"CAMELEER_EXPORT_TYPE", "HTTP",
|
"CAMELEER_EXPORT_TYPE", "HTTP",
|
||||||
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(),
|
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleerServerEndpoint(),
|
||||||
"CAMELEER_APPLICATION_ID", app.getSlug(),
|
"CAMELEER_APPLICATION_ID", app.getSlug(),
|
||||||
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
||||||
"CAMELEER_DISPLAY_NAME", containerName);
|
"CAMELEER_DISPLAY_NAME", containerName);
|
||||||
@@ -3418,7 +3418,7 @@ volumes:
|
|||||||
Add to the cameleer-saas service environment:
|
Add to the cameleer-saas service environment:
|
||||||
```yaml
|
```yaml
|
||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||||
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
|
CAMELEER_SERVER_ENDPOINT: http://cameleer-server:8081
|
||||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -3427,7 +3427,7 @@ Add to the cameleer-saas service volumes:
|
|||||||
- jardata:/data/jars
|
- jardata:/data/jars
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `CAMELEER_AUTH_TOKEN` to the cameleer3-server service environment:
|
Add `CAMELEER_AUTH_TOKEN` to the cameleer-server service environment:
|
||||||
```yaml
|
```yaml
|
||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||||
```
|
```
|
||||||
@@ -3448,7 +3448,7 @@ FROM eclipse-temurin:21-jre-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Agent JAR is copied during CI build from Gitea Maven registry
|
# Agent JAR is copied during CI build from Gitea Maven registry
|
||||||
# ARG AGENT_JAR=cameleer3-agent-1.0-SNAPSHOT-shaded.jar
|
# ARG AGENT_JAR=cameleer-agent-1.0-SNAPSHOT-shaded.jar
|
||||||
COPY agent.jar /app/agent.jar
|
COPY agent.jar /app/agent.jar
|
||||||
|
|
||||||
ENTRYPOINT exec java \
|
ENTRYPOINT exec java \
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer3-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
|
**Goal:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
|
||||||
|
|
||||||
**Architecture:** Wiring phase — cameleer3-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
|
**Architecture:** Wiring phase — cameleer-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
|
||||||
|
|
||||||
**Tech Stack:** Spring Boot 3.4.3, docker-java 3.4.1, ClickHouse JDBC, Traefik v3 labels, Spring RestClient
|
**Tech Stack:** Spring Boot 3.4.3, docker-java 3.4.1, ClickHouse JDBC, Traefik v3 labels, Spring RestClient
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer3-server for agent registration
|
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer-server for agent registration
|
||||||
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java` — Agent status + observability status endpoints
|
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java` — Agent status + observability status endpoints
|
||||||
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java` — Response DTO
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java` — Response DTO
|
||||||
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java` — Response DTO
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java` — Response DTO
|
||||||
@@ -359,7 +359,7 @@ class AgentStatusServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
|
when(runtimeConfig.getCameleerServerEndpoint()).thenReturn("http://cameleer-server:8081");
|
||||||
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ public class AgentStatusService {
|
|||||||
this.environmentRepository = environmentRepository;
|
this.environmentRepository = environmentRepository;
|
||||||
this.runtimeConfig = runtimeConfig;
|
this.runtimeConfig = runtimeConfig;
|
||||||
this.restClient = RestClient.builder()
|
this.restClient = RestClient.builder()
|
||||||
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
.baseUrl(runtimeConfig.getCameleerServerEndpoint())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +475,7 @@ public class AgentStatusService {
|
|||||||
return new AgentStatusResponse(false, "NOT_REGISTERED", null,
|
return new AgentStatusResponse(false, "NOT_REGISTERED", null,
|
||||||
List.of(), app.getSlug(), env.getSlug());
|
List.of(), app.getSlug(), env.getSlug());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to query agent status from cameleer3-server: {}", e.getMessage());
|
log.warn("Failed to query agent status from cameleer-server: {}", e.getMessage());
|
||||||
return new AgentStatusResponse(false, "UNKNOWN", null,
|
return new AgentStatusResponse(false, "UNKNOWN", null,
|
||||||
List.of(), app.getSlug(), env.getSlug());
|
List.of(), app.getSlug(), env.getSlug());
|
||||||
}
|
}
|
||||||
@@ -651,28 +651,28 @@ public class ConnectivityHealthCheck {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void verifyConnectivity() {
|
public void verifyConnectivity() {
|
||||||
checkCameleer3Server();
|
checkCameleerServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkCameleer3Server() {
|
private void checkCameleerServer() {
|
||||||
try {
|
try {
|
||||||
var client = RestClient.builder()
|
var client = RestClient.builder()
|
||||||
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
.baseUrl(runtimeConfig.getCameleerServerEndpoint())
|
||||||
.build();
|
.build();
|
||||||
var response = client.get()
|
var response = client.get()
|
||||||
.uri("/actuator/health")
|
.uri("/actuator/health")
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.toBodilessEntity();
|
.toBodilessEntity();
|
||||||
if (response.getStatusCode().is2xxSuccessful()) {
|
if (response.getStatusCode().is2xxSuccessful()) {
|
||||||
log.info("cameleer3-server connectivity: OK ({})",
|
log.info("cameleer-server connectivity: OK ({})",
|
||||||
runtimeConfig.getCameleer3ServerEndpoint());
|
runtimeConfig.getCameleerServerEndpoint());
|
||||||
} else {
|
} else {
|
||||||
log.warn("cameleer3-server connectivity: HTTP {} ({})",
|
log.warn("cameleer-server connectivity: HTTP {} ({})",
|
||||||
response.getStatusCode(), runtimeConfig.getCameleer3ServerEndpoint());
|
response.getStatusCode(), runtimeConfig.getCameleerServerEndpoint());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("cameleer3-server connectivity: FAILED ({}) - {}",
|
log.warn("cameleer-server connectivity: FAILED ({}) - {}",
|
||||||
runtimeConfig.getCameleer3ServerEndpoint(), e.getMessage());
|
runtimeConfig.getCameleerServerEndpoint(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,7 +686,7 @@ Run: `mvn compile -B -q`
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java
|
git add src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java
|
||||||
git commit -m "feat: add cameleer3-server startup connectivity check"
|
git commit -m "feat: add cameleer-server startup connectivity check"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -700,7 +700,7 @@ git commit -m "feat: add cameleer3-server startup connectivity check"
|
|||||||
|
|
||||||
- [ ] **Step 1: Update docker-compose.yml — add dashboard route and CAMELEER_TENANT_ID**
|
- [ ] **Step 1: Update docker-compose.yml — add dashboard route and CAMELEER_TENANT_ID**
|
||||||
|
|
||||||
In the `cameleer3-server` service:
|
In the `cameleer-server` service:
|
||||||
|
|
||||||
Add to environment section:
|
Add to environment section:
|
||||||
```yaml
|
```yaml
|
||||||
@@ -774,7 +774,7 @@ git commit -m "docs: update HOWTO with observability dashboard, routing, and age
|
|||||||
|
|
||||||
| Spec Requirement | Task |
|
| Spec Requirement | Task |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Serve cameleer3-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
|
| Serve cameleer-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
|
||||||
| CAMELEER_TENANT_ID configuration | Task 7 (docker-compose env) |
|
| CAMELEER_TENANT_ID configuration | Task 7 (docker-compose env) |
|
||||||
| Agent connectivity verification endpoint | Task 4 (AgentStatusService + Controller) |
|
| Agent connectivity verification endpoint | Task 4 (AgentStatusService + Controller) |
|
||||||
| Observability data health endpoint | Task 4 (ObservabilityStatusResponse) |
|
| Observability data health endpoint | Task 4 (ObservabilityStatusResponse) |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Goal:** Build a React SPA for managing tenants, environments, apps, and deployments. All backend APIs exist — this is the UI layer.
|
**Goal:** Build a React SPA for managing tenants, environments, apps, and deployments. All backend APIs exist — this is the UI layer.
|
||||||
|
|
||||||
**Architecture:** React 19 + Vite + React Router + Zustand + TanStack Query + @cameleer/design-system. Sidebar layout matching cameleer3-server SPA. Shared Logto OIDC session. RBAC on all actions. Lives in `ui/` directory, built into Spring Boot static resources.
|
**Architecture:** React 19 + Vite + React Router + Zustand + TanStack Query + @cameleer/design-system. Sidebar layout matching cameleer-server SPA. Shared Logto OIDC session. RBAC on all actions. Lives in `ui/` directory, built into Spring Boot static resources.
|
||||||
|
|
||||||
**Tech Stack:** React 19, Vite 8, TypeScript, React Router 7, Zustand, TanStack React Query, @cameleer/design-system 0.1.31, Lucide React
|
**Tech Stack:** React 19, Vite 8, TypeScript, React Router 7, Zustand, TanStack React Query, @cameleer/design-system 0.1.31, Lucide React
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ git commit -m "feat: scaffold React SPA with Vite, design system, and TypeScript
|
|||||||
|
|
||||||
- [ ] **Step 1: Create auth-store.ts**
|
- [ ] **Step 1: Create auth-store.ts**
|
||||||
|
|
||||||
Zustand store for auth state. Same localStorage keys as cameleer3-server SPA for SSO.
|
Zustand store for auth state. Same localStorage keys as cameleer-server SPA for SSO.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
@@ -1145,7 +1145,7 @@ git commit -m "feat: add SPA controller, Traefik route, CI frontend build, and H
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Project scaffolding (Vite, React, TS, design system) | Task 1 |
|
| Project scaffolding (Vite, React, TS, design system) | Task 1 |
|
||||||
| TypeScript API types | Task 1 |
|
| TypeScript API types | Task 1 |
|
||||||
| Auth store (Zustand, same keys as cameleer3-server) | Task 2 |
|
| Auth store (Zustand, same keys as cameleer-server) | Task 2 |
|
||||||
| Login / Logto OIDC redirect / callback | Task 2 |
|
| Login / Logto OIDC redirect / callback | Task 2 |
|
||||||
| Protected route | Task 2 |
|
| Protected route | Task 2 |
|
||||||
| API client with auth middleware | Task 3 |
|
| API client with auth middleware | Task 3 |
|
||||||
|
|||||||
@@ -2,35 +2,35 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer3-server for M2M.
|
**Goal:** Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer-server for M2M.
|
||||||
|
|
||||||
**Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer3-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
|
**Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
|
||||||
|
|
||||||
**Tech Stack:** Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway
|
**Tech Stack:** Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-04-05-auth-overhaul-design.md`
|
**Spec:** `docs/superpowers/specs/2026-04-05-auth-overhaul-design.md`
|
||||||
|
|
||||||
**Repos:**
|
**Repos:**
|
||||||
- cameleer3-server: `C:\Users\Hendrik\Documents\projects\cameleer3-server` (Phase 1)
|
- cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1)
|
||||||
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
|
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
|
||||||
- cameleer3 (agent): NO CHANGES
|
- cameleer (agent): NO CHANGES
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1: cameleer3-server — OIDC Resource Server Support
|
## Phase 1: cameleer-server — OIDC Resource Server Support
|
||||||
|
|
||||||
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer3-server`.
|
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server`.
|
||||||
|
|
||||||
### Task 1: Add OAuth2 Resource Server dependency and config properties
|
### Task 1: Add OAuth2 Resource Server dependency and config properties
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/pom.xml`
|
- Modify: `cameleer-server-app/pom.xml`
|
||||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Add dependency to pom.xml**
|
- [ ] **Step 1: Add dependency to pom.xml**
|
||||||
|
|
||||||
In `cameleer3-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
|
In `cameleer-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -41,7 +41,7 @@ In `cameleer3-server-app/pom.xml`, add after the `spring-boot-starter-security`
|
|||||||
|
|
||||||
- [ ] **Step 2: Add OIDC properties to application.yml**
|
- [ ] **Step 2: Add OIDC properties to application.yml**
|
||||||
|
|
||||||
In `cameleer3-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52):
|
In `cameleer-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
|
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
|
||||||
@@ -50,7 +50,7 @@ In `cameleer3-server-app/src/main/resources/application.yml`, add two new proper
|
|||||||
|
|
||||||
- [ ] **Step 3: Add fields to SecurityProperties.java**
|
- [ ] **Step 3: Add fields to SecurityProperties.java**
|
||||||
|
|
||||||
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19):
|
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
private String oidcIssuerUri;
|
private String oidcIssuerUri;
|
||||||
@@ -64,13 +64,13 @@ public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudie
|
|||||||
|
|
||||||
- [ ] **Step 4: Verify build compiles**
|
- [ ] **Step 4: Verify build compiles**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw compile -pl cameleer3-server-app -q`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw compile -pl cameleer-server-app -q`
|
||||||
Expected: BUILD SUCCESS
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/pom.xml cameleer3-server-app/src/main/resources/application.yml cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
|
git add cameleer-server-app/pom.xml cameleer-server-app/src/main/resources/application.yml cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java
|
||||||
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
|
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,14 +79,14 @@ git commit -m "feat: add oauth2-resource-server dependency and OIDC config prope
|
|||||||
### Task 2: Add conditional OIDC JwtDecoder bean
|
### Task 2: Add conditional OIDC JwtDecoder bean
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
Create `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java`:
|
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.security;
|
package com.cameleer.server.app.security;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
@@ -123,12 +123,12 @@ class OidcJwtDecoderBeanTest {
|
|||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||||||
Expected: FAIL — method `oidcJwtDecoder` does not exist
|
Expected: FAIL — method `oidcJwtDecoder` does not exist
|
||||||
|
|
||||||
- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**
|
- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**
|
||||||
|
|
||||||
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`, add these imports at the top:
|
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`, add these imports at the top:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
import com.nimbusds.jose.JWSAlgorithm;
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
@@ -216,13 +216,13 @@ Update the test to match: the test calls `config.oidcJwtDecoder(properties)` dir
|
|||||||
|
|
||||||
- [ ] **Step 5: Run test to verify it passes**
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||||||
Expected: PASS
|
Expected: PASS
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java
|
||||||
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
|
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -231,18 +231,18 @@ git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token val
|
|||||||
### Task 3: Update JwtAuthenticationFilter with OIDC fallback
|
### Task 3: Update JwtAuthenticationFilter with OIDC fallback
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
Create `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java`:
|
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.security;
|
package com.cameleer.server.app.security;
|
||||||
|
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
import com.cameleer.server.core.security.InvalidTokenException;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer.server.core.security.JwtService;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -369,19 +369,19 @@ class JwtAuthenticationFilterOidcTest {
|
|||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||||||
Expected: FAIL — constructor doesn't accept 3 args
|
Expected: FAIL — constructor doesn't accept 3 args
|
||||||
|
|
||||||
- [ ] **Step 3: Update JwtAuthenticationFilter**
|
- [ ] **Step 3: Update JwtAuthenticationFilter**
|
||||||
|
|
||||||
Replace `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` with:
|
Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.security;
|
package com.cameleer.server.app.security;
|
||||||
|
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer.server.core.security.JwtService;
|
||||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -508,13 +508,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||||||
Expected: PASS (all 4 tests)
|
Expected: PASS (all 4 tests)
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java
|
||||||
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
|
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -523,8 +523,8 @@ git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
|
|||||||
### Task 4: Wire OIDC decoder into SecurityConfig
|
### Task 4: Wire OIDC decoder into SecurityConfig
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Add OIDC decoder bean creation to SecurityBeanConfig**
|
- [ ] **Step 1: Add OIDC decoder bean creation to SecurityBeanConfig**
|
||||||
|
|
||||||
@@ -595,13 +595,13 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
|
|||||||
|
|
||||||
- [ ] **Step 3: Run existing tests**
|
- [ ] **Step 3: Run existing tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -q`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -q`
|
||||||
Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
|
Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java
|
||||||
git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"
|
git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1685,9 +1685,9 @@ In `docker-compose.yml`, remove these two labels from `cameleer-saas` (lines 122
|
|||||||
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Remove ForwardAuth middleware from cameleer3-server**
|
- [ ] **Step 2: Remove ForwardAuth middleware from cameleer-server**
|
||||||
|
|
||||||
In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer3-server` (lines 158-159):
|
In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer-server` (lines 158-159):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- traefik.http.routers.observe.middlewares=forward-auth
|
- traefik.http.routers.observe.middlewares=forward-auth
|
||||||
@@ -1719,7 +1719,7 @@ In `cameleer-saas` environment, remove:
|
|||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||||
```
|
```
|
||||||
|
|
||||||
In `cameleer3-server` environment, add:
|
In `cameleer-server` environment, add:
|
||||||
```yaml
|
```yaml
|
||||||
CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
|
CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
|
||||||
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
||||||
|
|||||||
@@ -8,41 +8,41 @@
|
|||||||
|
|
||||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, PostgreSQL 16, Flyway, JUnit 5, Testcontainers, AssertJ
|
**Tech Stack:** Java 17, Spring Boot 3.4.3, PostgreSQL 16, Flyway, JUnit 5, Testcontainers, AssertJ
|
||||||
|
|
||||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File Map
|
## File Map
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
- `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
- `cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java`
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java`
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java`
|
||||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
|
- `cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java`
|
||||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
|
- `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcOnlyModeIT.java`
|
- `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcOnlyModeIT.java`
|
||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
- `cameleer3-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
|
- `cameleer-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` — conditional endpoint registration
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` — conditional endpoint registration
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
|
||||||
- `cameleer3-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists)
|
- `cameleer-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 1: Database Migration — Add Origin Tracking and Claim Mapping Rules
|
### Task 1: Database Migration — Add Origin Tracking and Claim Mapping Rules
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
- Create: `cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the migration**
|
- [ ] **Step 1: Write the migration**
|
||||||
|
|
||||||
@@ -90,14 +90,14 @@ CREATE INDEX idx_user_groups_origin ON user_groups(user_id, origin);
|
|||||||
|
|
||||||
- [ ] **Step 2: Run migration to verify**
|
- [ ] **Step 2: Run migration to verify**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn flyway:migrate -pl cameleer3-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer3 -Dflyway.user=cameleer -Dflyway.password=cameleer_dev`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn flyway:migrate -pl cameleer-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer -Dflyway.user=cameleer -Dflyway.password=cameleer_dev`
|
||||||
|
|
||||||
If no local PostgreSQL, verify syntax by running the existing test suite which uses Testcontainers.
|
If no local PostgreSQL, verify syntax by running the existing test suite which uses Testcontainers.
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql
|
git add cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql
|
||||||
git commit -m "feat: add claim mapping rules table and origin tracking to RBAC assignments"
|
git commit -m "feat: add claim mapping rules table and origin tracking to RBAC assignments"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,14 +106,14 @@ git commit -m "feat: add claim mapping rules table and origin tracking to RBAC a
|
|||||||
### Task 2: Core Domain — ClaimMappingRule, AssignmentOrigin, Repository Interface
|
### Task 2: Core Domain — ClaimMappingRule, AssignmentOrigin, Repository Interface
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java`
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java`
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Create AssignmentOrigin enum**
|
- [ ] **Step 1: Create AssignmentOrigin enum**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.rbac;
|
package com.cameleer.server.core.rbac;
|
||||||
|
|
||||||
public enum AssignmentOrigin {
|
public enum AssignmentOrigin {
|
||||||
direct, managed
|
direct, managed
|
||||||
@@ -123,7 +123,7 @@ public enum AssignmentOrigin {
|
|||||||
- [ ] **Step 2: Create ClaimMappingRule record**
|
- [ ] **Step 2: Create ClaimMappingRule record**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.rbac;
|
package com.cameleer.server.core.rbac;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -146,7 +146,7 @@ public record ClaimMappingRule(
|
|||||||
- [ ] **Step 3: Create ClaimMappingRepository interface**
|
- [ ] **Step 3: Create ClaimMappingRepository interface**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.rbac;
|
package com.cameleer.server.core.rbac;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -164,9 +164,9 @@ public interface ClaimMappingRepository {
|
|||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java
|
||||||
git commit -m "feat: add ClaimMappingRule domain model and repository interface"
|
git commit -m "feat: add ClaimMappingRule domain model and repository interface"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -175,13 +175,13 @@ git commit -m "feat: add ClaimMappingRule domain model and repository interface"
|
|||||||
### Task 3: Core Domain — ClaimMappingService
|
### Task 3: Core Domain — ClaimMappingService
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java`
|
||||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
|
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Write tests for ClaimMappingService**
|
- [ ] **Step 1: Write tests for ClaimMappingService**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.rbac;
|
package com.cameleer.server.core.rbac;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -300,13 +300,13 @@ class ClaimMappingServiceTest {
|
|||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||||
Expected: Compilation error — ClaimMappingService does not exist yet.
|
Expected: Compilation error — ClaimMappingService does not exist yet.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement ClaimMappingService**
|
- [ ] **Step 3: Implement ClaimMappingService**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.rbac;
|
package com.cameleer.server.core.rbac;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -377,14 +377,14 @@ public class ClaimMappingService {
|
|||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest`
|
||||||
Expected: All 7 tests PASS.
|
Expected: All 7 tests PASS.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java
|
||||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java
|
git add cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java
|
||||||
git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching"
|
git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -393,15 +393,15 @@ git commit -m "feat: implement ClaimMappingService with equals/contains/regex ma
|
|||||||
### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation
|
### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Implement PostgresClaimMappingRepository**
|
- [ ] **Step 1: Implement PostgresClaimMappingRepository**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.storage;
|
package com.cameleer.server.app.storage;
|
||||||
|
|
||||||
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
import com.cameleer.server.core.rbac.ClaimMappingRepository;
|
||||||
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
import com.cameleer.server.core.rbac.ClaimMappingRule;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -479,7 +479,7 @@ public class PostgresClaimMappingRepository implements ClaimMappingRepository {
|
|||||||
|
|
||||||
- [ ] **Step 2: Wire the bean in AgentRegistryBeanConfig (or a new RbacBeanConfig)**
|
- [ ] **Step 2: Wire the bean in AgentRegistryBeanConfig (or a new RbacBeanConfig)**
|
||||||
|
|
||||||
Add to `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`):
|
Add to `cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Bean
|
@Bean
|
||||||
@@ -496,8 +496,8 @@ public ClaimMappingService claimMappingService() {
|
|||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java
|
||||||
git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
|
git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -506,11 +506,11 @@ git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
|
|||||||
### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments
|
### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Add managed assignment methods to RbacService interface**
|
- [ ] **Step 1: Add managed assignment methods to RbacService interface**
|
||||||
|
|
||||||
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java`, add:
|
In `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java`, add:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
void clearManagedAssignments(String userId);
|
void clearManagedAssignments(String userId);
|
||||||
@@ -592,14 +592,14 @@ public List<RoleSummary> getDirectRolesForUser(String userId) {
|
|||||||
|
|
||||||
- [ ] **Step 5: Run existing tests**
|
- [ ] **Step 5: Run existing tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||||
Expected: All existing tests still pass (migration adds columns with defaults).
|
Expected: All existing tests still pass (migration adds columns with defaults).
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java
|
||||||
git commit -m "feat: add origin-aware managed/direct assignment methods to RbacService"
|
git commit -m "feat: add origin-aware managed/direct assignment methods to RbacService"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ git commit -m "feat: add origin-aware managed/direct assignment methods to RbacS
|
|||||||
### Task 6: Modify OidcAuthController — Replace syncOidcRoles with Claim Mapping
|
### Task 6: Modify OidcAuthController — Replace syncOidcRoles with Claim Mapping
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Inject ClaimMappingService and ClaimMappingRepository**
|
- [ ] **Step 1: Inject ClaimMappingService and ClaimMappingRepository**
|
||||||
|
|
||||||
@@ -676,13 +676,13 @@ Note: `extractAllClaims` needs to be added to `OidcTokenExchanger` — it return
|
|||||||
|
|
||||||
- [ ] **Step 4: Run existing tests**
|
- [ ] **Step 4: Run existing tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||||
Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).
|
Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java
|
||||||
git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC login"
|
git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC login"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -691,8 +691,8 @@ git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC
|
|||||||
### Task 7: OIDC-Only Mode — Disable Local Auth When OIDC Configured
|
### Task 7: OIDC-Only Mode — Disable Local Auth When OIDC Configured
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
|
||||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
|
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Add isOidcEnabled() helper to SecurityConfig**
|
- [ ] **Step 1: Add isOidcEnabled() helper to SecurityConfig**
|
||||||
|
|
||||||
@@ -760,15 +760,15 @@ public ResponseEntity<?> resetPassword(@PathVariable String userId, @RequestBody
|
|||||||
|
|
||||||
- [ ] **Step 5: Run full test suite**
|
- [ ] **Step 5: Run full test suite**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java
|
||||||
git commit -m "feat: disable local auth when OIDC is configured (resource server mode)"
|
git commit -m "feat: disable local auth when OIDC is configured (resource server mode)"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -777,15 +777,15 @@ git commit -m "feat: disable local auth when OIDC is configured (resource server
|
|||||||
### Task 8: Claim Mapping Admin Controller
|
### Task 8: Claim Mapping Admin Controller
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Implement the controller**
|
- [ ] **Step 1: Implement the controller**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
import com.cameleer.server.core.rbac.ClaimMappingRepository;
|
||||||
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
import com.cameleer.server.core.rbac.ClaimMappingRule;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -867,13 +867,13 @@ In `SecurityConfig.filterChain()`, the `/api/v1/admin/**` path already requires
|
|||||||
|
|
||||||
- [ ] **Step 3: Run full test suite**
|
- [ ] **Step 3: Run full test suite**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java
|
||||||
git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
|
git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -882,14 +882,14 @@ git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
|
|||||||
### Task 9: Integration Test — Claim Mapping End-to-End
|
### Task 9: Integration Test — Claim Mapping End-to-End
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
|
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Write integration test**
|
- [ ] **Step 1: Write integration test**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
import com.cameleer.server.app.AbstractPostgresIT;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -954,13 +954,13 @@ class ClaimMappingAdminControllerIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
- [ ] **Step 2: Run integration tests**
|
- [ ] **Step 2: Run integration tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingAdminControllerIT`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingAdminControllerIT`
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java
|
git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java
|
||||||
git commit -m "test: add integration tests for claim mapping admin API"
|
git commit -m "test: add integration tests for claim mapping admin API"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -970,12 +970,12 @@ git commit -m "test: add integration tests for claim mapping admin API"
|
|||||||
|
|
||||||
- [ ] **Step 1: Run all tests**
|
- [ ] **Step 1: Run all tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||||
Expected: All tests PASS. Build succeeds.
|
Expected: All tests PASS. Build succeeds.
|
||||||
|
|
||||||
- [ ] **Step 2: Verify migration applies cleanly on fresh database**
|
- [ ] **Step 2: Verify migration applies cleanly on fresh database**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=AbstractPostgresIT`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=AbstractPostgresIT`
|
||||||
Expected: Testcontainers starts fresh PostgreSQL, Flyway applies V1 + V2, context loads.
|
Expected: Testcontainers starts fresh PostgreSQL, Flyway applies V1 + V2, context loads.
|
||||||
|
|
||||||
- [ ] **Step 3: Commit any remaining fixes**
|
- [ ] **Step 3: Commit any remaining fixes**
|
||||||
|
|||||||
@@ -8,37 +8,37 @@
|
|||||||
|
|
||||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ
|
**Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ
|
||||||
|
|
||||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File Map
|
## File Map
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java`
|
||||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java`
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`
|
||||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java`
|
||||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
- `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java`
|
||||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
- `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java`
|
||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
- `cameleer3-server-app/src/main/resources/application.yml` — add license config properties
|
- `cameleer-server-app/src/main/resources/application.yml` — add license config properties
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 1: Core Domain — LicenseInfo, Feature Enum
|
### Task 1: Core Domain — LicenseInfo, Feature Enum
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java`
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Create Feature enum**
|
- [ ] **Step 1: Create Feature enum**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.license;
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
public enum Feature {
|
public enum Feature {
|
||||||
topology,
|
topology,
|
||||||
@@ -52,7 +52,7 @@ public enum Feature {
|
|||||||
- [ ] **Step 2: Create LicenseInfo record**
|
- [ ] **Step 2: Create LicenseInfo record**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.license;
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -87,8 +87,8 @@ public record LicenseInfo(
|
|||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java
|
||||||
git commit -m "feat: add LicenseInfo and Feature domain model"
|
git commit -m "feat: add LicenseInfo and Feature domain model"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,13 +97,13 @@ git commit -m "feat: add LicenseInfo and Feature domain model"
|
|||||||
### Task 2: LicenseValidator — Ed25519 JWT Verification
|
### Task 2: LicenseValidator — Ed25519 JWT Verification
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java`
|
||||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Write tests**
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.license;
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -194,13 +194,13 @@ class LicenseValidatorTest {
|
|||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||||
Expected: Compilation error — LicenseValidator does not exist.
|
Expected: Compilation error — LicenseValidator does not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement LicenseValidator**
|
- [ ] **Step 3: Implement LicenseValidator**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.license;
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -298,14 +298,14 @@ public class LicenseValidator {
|
|||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseValidatorTest`
|
||||||
Expected: All 3 tests PASS.
|
Expected: All 3 tests PASS.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java
|
||||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java
|
git add cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java
|
||||||
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
|
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -314,13 +314,13 @@ git commit -m "feat: implement LicenseValidator with Ed25519 signature verificat
|
|||||||
### Task 3: LicenseGate — Feature Check Service
|
### Task 3: LicenseGate — Feature Check Service
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java`
|
||||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Write tests**
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.license;
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ class LicenseGateTest {
|
|||||||
- [ ] **Step 2: Implement LicenseGate**
|
- [ ] **Step 2: Implement LicenseGate**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.license;
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -405,14 +405,14 @@ public class LicenseGate {
|
|||||||
|
|
||||||
- [ ] **Step 3: Run tests**
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseGateTest`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseGateTest`
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java
|
||||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java
|
git add cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java
|
||||||
git commit -m "feat: implement LicenseGate for feature checking"
|
git commit -m "feat: implement LicenseGate for feature checking"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -421,8 +421,8 @@ git commit -m "feat: implement LicenseGate for feature checking"
|
|||||||
### Task 4: License Loading — Bean Config and Startup
|
### Task 4: License Loading — Bean Config and Startup
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`
|
||||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||||||
|
|
||||||
- [ ] **Step 1: Add license config properties to application.yml**
|
- [ ] **Step 1: Add license config properties to application.yml**
|
||||||
|
|
||||||
@@ -436,11 +436,11 @@ license:
|
|||||||
- [ ] **Step 2: Implement LicenseBeanConfig**
|
- [ ] **Step 2: Implement LicenseBeanConfig**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.config;
|
package com.cameleer.server.app.config;
|
||||||
|
|
||||||
import com.cameleer3.server.core.license.LicenseGate;
|
import com.cameleer.server.core.license.LicenseGate;
|
||||||
import com.cameleer3.server.core.license.LicenseInfo;
|
import com.cameleer.server.core.license.LicenseInfo;
|
||||||
import com.cameleer3.server.core.license.LicenseValidator;
|
import com.cameleer.server.core.license.LicenseValidator;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -509,8 +509,8 @@ public class LicenseBeanConfig {
|
|||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java
|
||||||
git add cameleer3-server-app/src/main/resources/application.yml
|
git add cameleer-server-app/src/main/resources/application.yml
|
||||||
git commit -m "feat: add license loading at startup from env var or file"
|
git commit -m "feat: add license loading at startup from env var or file"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -519,16 +519,16 @@ git commit -m "feat: add license loading at startup from env var or file"
|
|||||||
### Task 5: License Admin API — Runtime License Update
|
### Task 5: License Admin API — Runtime License Update
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java`
|
||||||
|
|
||||||
- [ ] **Step 1: Implement controller**
|
- [ ] **Step 1: Implement controller**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.server.core.license.LicenseGate;
|
import com.cameleer.server.core.license.LicenseGate;
|
||||||
import com.cameleer3.server.core.license.LicenseInfo;
|
import com.cameleer.server.core.license.LicenseInfo;
|
||||||
import com.cameleer3.server.core.license.LicenseValidator;
|
import com.cameleer.server.core.license.LicenseValidator;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -581,13 +581,13 @@ public class LicenseAdminController {
|
|||||||
|
|
||||||
- [ ] **Step 2: Run full test suite**
|
- [ ] **Step 2: Run full test suite**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java
|
||||||
git commit -m "feat: add license admin API for runtime license updates"
|
git commit -m "feat: add license admin API for runtime license updates"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -611,5 +611,5 @@ public ResponseEntity<?> listDebugSessions() {
|
|||||||
|
|
||||||
- [ ] **Step 2: Final verification**
|
- [ ] **Step 2: Final verification**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||||
Expected: All tests PASS.
|
Expected: All tests PASS.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plan 3: Runtime Management in the Server
|
# Plan 3: Runtime Management in the Server
|
||||||
|
|
||||||
> **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer3-server with enhancements beyond the original plan.
|
> **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer-server with enhancements beyond the original plan.
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
|
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
|
||||||
|
|
||||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
|
||||||
|
|
||||||
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
|
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
|
||||||
|
|
||||||
@@ -18,10 +18,10 @@
|
|||||||
|
|
||||||
## File Map
|
## File Map
|
||||||
|
|
||||||
### New Files — Core Module (`cameleer3-server-core`)
|
### New Files — Core Module (`cameleer-server-core`)
|
||||||
|
|
||||||
```
|
```
|
||||||
src/main/java/com/cameleer3/server/core/runtime/
|
src/main/java/com/cameleer/server/core/runtime/
|
||||||
├── Environment.java Record: id, slug, displayName, status, createdAt
|
├── Environment.java Record: id, slug, displayName, status, createdAt
|
||||||
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
|
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
|
||||||
├── EnvironmentRepository.java Interface: CRUD + findBySlug
|
├── EnvironmentRepository.java Interface: CRUD + findBySlug
|
||||||
@@ -42,10 +42,10 @@ src/main/java/com/cameleer3/server/core/runtime/
|
|||||||
└── RoutingMode.java Enum: path, subdomain
|
└── RoutingMode.java Enum: path, subdomain
|
||||||
```
|
```
|
||||||
|
|
||||||
### New Files — App Module (`cameleer3-server-app`)
|
### New Files — App Module (`cameleer-server-app`)
|
||||||
|
|
||||||
```
|
```
|
||||||
src/main/java/com/cameleer3/server/app/runtime/
|
src/main/java/com/cameleer/server/app/runtime/
|
||||||
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
|
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
|
||||||
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
|
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
|
||||||
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
|
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
|
||||||
@@ -53,13 +53,13 @@ src/main/java/com/cameleer3/server/app/runtime/
|
|||||||
├── JarStorageService.java File-system JAR storage with versioning
|
├── JarStorageService.java File-system JAR storage with versioning
|
||||||
└── ContainerLogCollector.java Collects Docker container stdout/stderr
|
└── ContainerLogCollector.java Collects Docker container stdout/stderr
|
||||||
|
|
||||||
src/main/java/com/cameleer3/server/app/storage/
|
src/main/java/com/cameleer/server/app/storage/
|
||||||
├── PostgresEnvironmentRepository.java
|
├── PostgresEnvironmentRepository.java
|
||||||
├── PostgresAppRepository.java
|
├── PostgresAppRepository.java
|
||||||
├── PostgresAppVersionRepository.java
|
├── PostgresAppVersionRepository.java
|
||||||
└── PostgresDeploymentRepository.java
|
└── PostgresDeploymentRepository.java
|
||||||
|
|
||||||
src/main/java/com/cameleer3/server/app/controller/
|
src/main/java/com/cameleer/server/app/controller/
|
||||||
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
|
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
|
||||||
├── AppController.java App + version CRUD + JAR upload
|
├── AppController.java App + version CRUD + JAR upload
|
||||||
└── DeploymentController.java Deploy, stop, restart, promote, logs
|
└── DeploymentController.java Deploy, stop, restart, promote, logs
|
||||||
@@ -70,7 +70,7 @@ src/main/resources/db/migration/
|
|||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
- `pom.xml` (parent) — add docker-java dependency
|
- `pom.xml` (parent) — add docker-java dependency
|
||||||
- `cameleer3-server-app/pom.xml` — add docker-java dependency
|
- `cameleer-server-app/pom.xml` — add docker-java dependency
|
||||||
- `application.yml` — add runtime config properties
|
- `application.yml` — add runtime config properties
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -78,7 +78,7 @@ src/main/resources/db/migration/
|
|||||||
### Task 1: Add docker-java Dependency
|
### Task 1: Add docker-java Dependency
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/pom.xml`
|
- Modify: `cameleer-server-app/pom.xml`
|
||||||
|
|
||||||
- [x] **Step 1: Add docker-java dependency**
|
- [x] **Step 1: Add docker-java dependency**
|
||||||
|
|
||||||
@@ -97,13 +97,13 @@ src/main/resources/db/migration/
|
|||||||
|
|
||||||
- [x] **Step 2: Verify build**
|
- [x] **Step 2: Verify build**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn compile -pl cameleer-server-app`
|
||||||
Expected: BUILD SUCCESS.
|
Expected: BUILD SUCCESS.
|
||||||
|
|
||||||
- [x] **Step 3: Commit**
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/pom.xml
|
git add cameleer-server-app/pom.xml
|
||||||
git commit -m "chore: add docker-java dependency for runtime orchestration"
|
git commit -m "chore: add docker-java dependency for runtime orchestration"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ git commit -m "chore: add docker-java dependency for runtime orchestration"
|
|||||||
### Task 2: Database Migration — Runtime Management Tables
|
### Task 2: Database Migration — Runtime Management Tables
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
|
- Create: `cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
|
||||||
|
|
||||||
- [x] **Step 1: Write migration**
|
- [x] **Step 1: Write migration**
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
|
|||||||
- [x] **Step 2: Commit**
|
- [x] **Step 2: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql
|
git add cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql
|
||||||
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
|
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,36 +185,36 @@ git commit -m "feat: add runtime management database schema (environments, apps,
|
|||||||
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
|
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create all records in `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/`
|
- Create all records in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/`
|
||||||
|
|
||||||
- [x] **Step 1: Create all domain records**
|
- [x] **Step 1: Create all domain records**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
// Environment.java
|
// Environment.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
|
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
|
||||||
|
|
||||||
// EnvironmentStatus.java
|
// EnvironmentStatus.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
|
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
|
||||||
|
|
||||||
// App.java
|
// App.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
|
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
|
||||||
|
|
||||||
// AppVersion.java
|
// AppVersion.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
|
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
|
||||||
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
|
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
|
||||||
|
|
||||||
// Deployment.java
|
// Deployment.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
|
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
|
||||||
@@ -227,18 +227,18 @@ public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmen
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeploymentStatus.java
|
// DeploymentStatus.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
|
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
|
||||||
|
|
||||||
// RoutingMode.java
|
// RoutingMode.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
public enum RoutingMode { path, subdomain }
|
public enum RoutingMode { path, subdomain }
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 2: Commit**
|
- [x] **Step 2: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
|
||||||
git commit -m "feat: add runtime management domain records"
|
git commit -m "feat: add runtime management domain records"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ git commit -m "feat: add runtime management domain records"
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
// EnvironmentRepository.java
|
// EnvironmentRepository.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
public interface EnvironmentRepository {
|
public interface EnvironmentRepository {
|
||||||
List<Environment> findAll();
|
List<Environment> findAll();
|
||||||
@@ -266,7 +266,7 @@ public interface EnvironmentRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AppRepository.java
|
// AppRepository.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
public interface AppRepository {
|
public interface AppRepository {
|
||||||
List<App> findByEnvironmentId(UUID environmentId);
|
List<App> findByEnvironmentId(UUID environmentId);
|
||||||
@@ -277,7 +277,7 @@ public interface AppRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AppVersionRepository.java
|
// AppVersionRepository.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
public interface AppVersionRepository {
|
public interface AppVersionRepository {
|
||||||
List<AppVersion> findByAppId(UUID appId);
|
List<AppVersion> findByAppId(UUID appId);
|
||||||
@@ -287,7 +287,7 @@ public interface AppVersionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeploymentRepository.java
|
// DeploymentRepository.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
public interface DeploymentRepository {
|
public interface DeploymentRepository {
|
||||||
List<Deployment> findByAppId(UUID appId);
|
List<Deployment> findByAppId(UUID appId);
|
||||||
@@ -305,7 +305,7 @@ public interface DeploymentRepository {
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
// RuntimeOrchestrator.java
|
// RuntimeOrchestrator.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
|
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ public interface RuntimeOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ContainerRequest.java
|
// ContainerRequest.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
public record ContainerRequest(
|
public record ContainerRequest(
|
||||||
String containerName,
|
String containerName,
|
||||||
@@ -334,7 +334,7 @@ public record ContainerRequest(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ContainerStatus.java
|
// ContainerStatus.java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
|
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
|
||||||
public static ContainerStatus notFound() {
|
public static ContainerStatus notFound() {
|
||||||
return new ContainerStatus("not_found", false, -1, "Container not found");
|
return new ContainerStatus("not_found", false, -1, "Container not found");
|
||||||
@@ -345,7 +345,7 @@ public record ContainerStatus(String state, boolean running, int exitCode, Strin
|
|||||||
- [x] **Step 3: Commit**
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
|
||||||
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
|||||||
- [x] **Step 1: Create EnvironmentService**
|
- [x] **Step 1: Create EnvironmentService**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -395,7 +395,7 @@ public class EnvironmentService {
|
|||||||
- [x] **Step 2: Create AppService**
|
- [x] **Step 2: Create AppService**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -478,7 +478,7 @@ public class AppService {
|
|||||||
- [x] **Step 3: Create DeploymentService**
|
- [x] **Step 3: Create DeploymentService**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -536,7 +536,7 @@ public class DeploymentService {
|
|||||||
- [x] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
|
||||||
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
|
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -598,14 +598,14 @@ public class RuntimeBeanConfig {
|
|||||||
|
|
||||||
- [x] **Step 3: Run tests**
|
- [x] **Step 3: Run tests**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||||
Expected: PASS (Flyway applies V3 migration, context loads).
|
Expected: PASS (Flyway applies V3 migration, context loads).
|
||||||
|
|
||||||
- [x] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/Postgres*Repository.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java
|
||||||
git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -614,16 +614,16 @@ git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
|||||||
### Task 7: Docker Runtime Orchestrator
|
### Task 7: Docker Runtime Orchestrator
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java`
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DisabledRuntimeOrchestrator.java`
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
|
||||||
|
|
||||||
- [x] **Step 1: Implement DisabledRuntimeOrchestrator**
|
- [x] **Step 1: Implement DisabledRuntimeOrchestrator**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.runtime;
|
package com.cameleer.server.app.runtime;
|
||||||
|
|
||||||
import com.cameleer3.server.core.runtime.*;
|
import com.cameleer.server.core.runtime.*;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
|
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
|
||||||
@@ -685,9 +685,9 @@ public String startContainer(ContainerRequest request) {
|
|||||||
- [x] **Step 3: Implement RuntimeOrchestratorAutoConfig**
|
- [x] **Step 3: Implement RuntimeOrchestratorAutoConfig**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.runtime;
|
package com.cameleer.server.app.runtime;
|
||||||
|
|
||||||
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
|
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -718,7 +718,7 @@ public class RuntimeOrchestratorAutoConfig {
|
|||||||
- [x] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/
|
||||||
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
|
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -727,14 +727,14 @@ git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR d
|
|||||||
### Task 8: DeploymentExecutor — Async Deployment Pipeline
|
### Task 8: DeploymentExecutor — Async Deployment Pipeline
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
|
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java`
|
||||||
|
|
||||||
- [x] **Step 1: Implement async deployment pipeline**
|
- [x] **Step 1: Implement async deployment pipeline**
|
||||||
|
|
||||||
```java
|
```java
|
||||||
package com.cameleer3.server.app.runtime;
|
package com.cameleer.server.app.runtime;
|
||||||
|
|
||||||
import com.cameleer3.server.core.runtime.*;
|
import com.cameleer.server.core.runtime.*;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
@@ -841,7 +841,7 @@ public TaskExecutor deploymentTaskExecutor() {
|
|||||||
- [x] **Step 3: Commit**
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
|
||||||
git commit -m "feat: implement async DeploymentExecutor pipeline"
|
git commit -m "feat: implement async DeploymentExecutor pipeline"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -907,9 +907,9 @@ Add to `SecurityConfig.filterChain()`:
|
|||||||
- [x] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java
|
||||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java
|
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java
|
||||||
git commit -m "feat: add REST controllers for environment, app, and deployment management"
|
git commit -m "feat: add REST controllers for environment, app, and deployment management"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -918,7 +918,7 @@ git commit -m "feat: add REST controllers for environment, app, and deployment m
|
|||||||
### Task 10: Configuration and Application Properties
|
### Task 10: Configuration and Application Properties
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||||||
|
|
||||||
- [x] **Step 1: Add runtime config properties**
|
- [x] **Step 1: Add runtime config properties**
|
||||||
|
|
||||||
@@ -939,13 +939,13 @@ cameleer:
|
|||||||
|
|
||||||
- [x] **Step 2: Run full test suite**
|
- [x] **Step 2: Run full test suite**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [x] **Step 3: Commit**
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/main/resources/application.yml
|
git add cameleer-server-app/src/main/resources/application.yml
|
||||||
git commit -m "feat: add runtime management configuration properties"
|
git commit -m "feat: add runtime management configuration properties"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -968,7 +968,7 @@ Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the de
|
|||||||
- [x] **Step 4: Commit**
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
|
git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/
|
||||||
git commit -m "test: add integration tests for runtime management API"
|
git commit -m "test: add integration tests for runtime management API"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -978,7 +978,7 @@ git commit -m "test: add integration tests for runtime management API"
|
|||||||
|
|
||||||
- [x] **Step 1: Run full build**
|
- [x] **Step 1: Run full build**
|
||||||
|
|
||||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||||
Expected: All tests PASS.
|
Expected: All tests PASS.
|
||||||
|
|
||||||
- [x] **Step 2: Verify schema applies cleanly**
|
- [x] **Step 2: Verify schema applies cleanly**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-saas`
|
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-saas`
|
||||||
|
|
||||||
**Prerequisite:** Plans 1-3 must be implemented in cameleer3-server first.
|
**Prerequisite:** Plans 1-3 must be implemented in cameleer-server first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ git commit -m "feat: remove migrated environment/app/deployment/runtime code fro
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- V010__drop_migrated_tables.sql
|
-- V010__drop_migrated_tables.sql
|
||||||
-- Drop tables that have been migrated to cameleer3-server
|
-- Drop tables that have been migrated to cameleer-server
|
||||||
|
|
||||||
DROP TABLE IF EXISTS deployments CASCADE;
|
DROP TABLE IF EXISTS deployments CASCADE;
|
||||||
DROP TABLE IF EXISTS apps CASCADE;
|
DROP TABLE IF EXISTS apps CASCADE;
|
||||||
@@ -242,7 +242,7 @@ group_add:
|
|||||||
- "0"
|
- "0"
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker socket mount now belongs to the `cameleer3-server` service instead.
|
The Docker socket mount now belongs to the `cameleer-server` service instead.
|
||||||
|
|
||||||
- [ ] **Step 2: Remove docker-java dependency from pom.xml**
|
- [ ] **Step 2: Remove docker-java dependency from pom.xml**
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ git commit -m "feat: expand ServerApiClient with license push and health check m
|
|||||||
|
|
||||||
- [ ] **Step 1: Create integration contract document**
|
- [ ] **Step 1: Create integration contract document**
|
||||||
|
|
||||||
Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
|
Create `docs/SAAS-INTEGRATION.md` in the cameleer-server repo documenting:
|
||||||
- Which server API endpoints the SaaS calls
|
- Which server API endpoints the SaaS calls
|
||||||
- Required auth (M2M token with `server:admin` scope)
|
- Required auth (M2M token with `server:admin` scope)
|
||||||
- License injection mechanism (`POST /api/v1/admin/license`)
|
- License injection mechanism (`POST /api/v1/admin/license`)
|
||||||
@@ -339,7 +339,7 @@ Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
|
|||||||
- [ ] **Step 2: Commit**
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /c/Users/Hendrik/Documents/projects/cameleer3-server
|
cd /c/Users/Hendrik/Documents/projects/cameleer-server
|
||||||
git add docs/SAAS-INTEGRATION.md
|
git add docs/SAAS-INTEGRATION.md
|
||||||
git commit -m "docs: add SaaS integration contract documentation"
|
git commit -m "docs: add SaaS integration contract documentation"
|
||||||
```
|
```
|
||||||
|
|||||||
3017
docs/superpowers/plans/2026-04-09-platform-redesign-plan.md
Normal file
760
docs/superpowers/plans/2026-04-09-saas-ux-polish-plan.md
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
# SaaS Platform UX Polish — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fix layout bugs, replace hardcoded dark-only colors with design system tokens, improve navigation/header, add error handling, and adopt design system components consistently across the SaaS platform UI.
|
||||||
|
|
||||||
|
**Architecture:** All changes are in the existing SaaS platform UI (`ui/src/`) and sign-in page (`ui/sign-in/src/`). The platform uses `@cameleer/design-system` components and Tailwind CSS. The key issue is that pages use hardcoded `text-white` Tailwind classes instead of DS CSS variables, and the DS `TopBar` renders server-specific controls that are irrelevant on platform pages.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript, Tailwind CSS, `@cameleer/design-system`, React Router v6, Logto SDK
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Fix label/value collision and replace hardcoded colors
|
||||||
|
|
||||||
|
**Spec items:** 1.1, 1.2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/src/styles/platform.module.css`
|
||||||
|
- Modify: `ui/src/pages/DashboardPage.tsx`
|
||||||
|
- Modify: `ui/src/pages/LicensePage.tsx`
|
||||||
|
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create shared platform CSS module**
|
||||||
|
|
||||||
|
Create `ui/src/styles/platform.module.css` with DS-variable-based classes replacing the hardcoded Tailwind colors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.heading {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textPrimary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textSecondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textMuted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kvRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kvLabel {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kvValue {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kvValueMono {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerList > * + * {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerRow:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerRow:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenBlock {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenCode {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update DashboardPage to use CSS module + fix label/value**
|
||||||
|
|
||||||
|
In `ui/src/pages/DashboardPage.tsx`:
|
||||||
|
|
||||||
|
1. Add import:
|
||||||
|
```typescript
|
||||||
|
import s from '../styles/platform.module.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Replace all hardcoded color classes:
|
||||||
|
- Line 71: `text-2xl font-semibold text-white` → `className={s.heading}`
|
||||||
|
- Lines 96, 100, 107: `className="flex justify-between text-white/80"` → `className={s.kvRow}`
|
||||||
|
- Inner label spans: wrap with `className={s.kvLabel}`
|
||||||
|
- Inner value spans: wrap with `className={s.kvValueMono}` (for mono) or `className={s.kvValue}`
|
||||||
|
- Line 116: `text-sm text-white/60` → `className={s.description}`
|
||||||
|
|
||||||
|
3. The label/value collision fix: the `kvRow` class uses explicit `display: flex; width: 100%; justify-content: space-between` which ensures the flex container stretches to full Card width regardless of Card's inner layout.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update LicensePage to use CSS module**
|
||||||
|
|
||||||
|
In `ui/src/pages/LicensePage.tsx`:
|
||||||
|
|
||||||
|
1. Add import: `import s from '../styles/platform.module.css';`
|
||||||
|
|
||||||
|
2. Replace all hardcoded color classes:
|
||||||
|
- Line 85: heading → `className={s.heading}`
|
||||||
|
- Lines 95-115 (Validity rows): `flex items-center justify-between` → `className={s.kvRow}`, labels → `className={s.kvLabel}`, values → `className={s.kvValue}`
|
||||||
|
- Lines 121-136 (Features): `divide-y divide-white/10` → `className={s.dividerList}`, rows → `className={s.dividerRow}`, feature name `text-sm text-white` → `className={s.textPrimary}` + `text-sm`
|
||||||
|
- Lines 142-157 (Limits): same dividerList/dividerRow pattern, label → `className={s.kvLabel}`, value → `className={s.kvValueMono}`
|
||||||
|
- Line 163: description text → `className={s.description}`
|
||||||
|
- Lines 174-178: token code block → `className={s.tokenBlock}` on outer div, `className={s.tokenCode}` on code element
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update AdminTenantsPage to use CSS module**
|
||||||
|
|
||||||
|
In `ui/src/pages/AdminTenantsPage.tsx`:
|
||||||
|
- Line 62: `text-2xl font-semibold text-white` → `className={s.heading}`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify in both themes**
|
||||||
|
|
||||||
|
1. Open the platform dashboard in browser
|
||||||
|
2. Check label/value pairs have proper spacing (Slug on left, "default" on right)
|
||||||
|
3. Toggle to light theme via TopBar toggle
|
||||||
|
4. Verify all text is readable in light mode (no invisible white-on-white)
|
||||||
|
5. Toggle back to dark mode — should look the same as before
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/styles/platform.module.css ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx ui/src/pages/AdminTenantsPage.tsx
|
||||||
|
git commit -m "fix: replace hardcoded text-white with DS variables, fix label/value layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Remove redundant dashboard elements
|
||||||
|
|
||||||
|
**Spec items:** 1.3, 2.4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/DashboardPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove primary "Open Server Dashboard" button from header**
|
||||||
|
|
||||||
|
In `ui/src/pages/DashboardPage.tsx`, find the header area (lines ~75-88). Remove the primary Button for "Open Server Dashboard" (lines ~81-87). Keep:
|
||||||
|
- The Server Management Card with its secondary button (lines ~113-126)
|
||||||
|
- The sidebar footer link (in Layout.tsx — don't touch)
|
||||||
|
|
||||||
|
The header area should just have the tenant name heading + tier badge, no button.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/pages/DashboardPage.tsx
|
||||||
|
git commit -m "fix: remove redundant Open Server Dashboard button from dashboard header"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Fix header controls and sidebar navigation
|
||||||
|
|
||||||
|
**Spec items:** 2.1, 2.2, 2.3, 2.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/components/Layout.tsx`
|
||||||
|
- Modify: `ui/src/main.tsx` (possibly)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Investigate TopBar props for hiding controls**
|
||||||
|
|
||||||
|
The DS `TopBar` interface (from types):
|
||||||
|
```typescript
|
||||||
|
interface TopBarProps {
|
||||||
|
breadcrumb: BreadcrumbItem[];
|
||||||
|
environment?: ReactNode;
|
||||||
|
user?: { name: string };
|
||||||
|
onLogout?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The TopBar has NO props to hide status filters, time range, auto-refresh, or search. These are hardcoded inside the component.
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
1. Check if removing `GlobalFilterProvider` and `CommandPaletteProvider` from `main.tsx` makes TopBar gracefully hide those sections (test this first)
|
||||||
|
2. If that causes errors, add `display: none` CSS overrides for the irrelevant sections
|
||||||
|
3. If neither works, build a simplified platform header
|
||||||
|
|
||||||
|
Try option 1 first. In `main.tsx`, remove `GlobalFilterProvider` and `CommandPaletteProvider` from the provider stack. Test if the app still renders. If TopBar crashes without them, revert and try option 2.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add sidebar active state**
|
||||||
|
|
||||||
|
In `ui/src/components/Layout.tsx`, add route-based active state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
// Inside the Layout component:
|
||||||
|
const location = useLocation();
|
||||||
|
```
|
||||||
|
|
||||||
|
Update each `Sidebar.Section`:
|
||||||
|
```tsx
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<DashboardIcon />}
|
||||||
|
label="Dashboard"
|
||||||
|
open={false}
|
||||||
|
active={location.pathname === '/' || location.pathname === ''}
|
||||||
|
onToggle={() => navigate('/')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<LicenseIcon />}
|
||||||
|
label="License"
|
||||||
|
open={false}
|
||||||
|
active={location.pathname === '/license'}
|
||||||
|
onToggle={() => navigate('/license')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
{scopes.has('platform:admin') && (
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<PlatformIcon />}
|
||||||
|
label="Platform"
|
||||||
|
open={false}
|
||||||
|
active={location.pathname.startsWith('/admin')}
|
||||||
|
onToggle={() => navigate('/admin/tenants')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add breadcrumbs**
|
||||||
|
|
||||||
|
In Layout.tsx, compute breadcrumbs from the current route:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const breadcrumb = useMemo((): BreadcrumbItem[] => {
|
||||||
|
const path = location.pathname;
|
||||||
|
if (path.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }];
|
||||||
|
if (path.startsWith('/license')) return [{ label: 'License' }];
|
||||||
|
return [{ label: 'Dashboard' }];
|
||||||
|
}, [location.pathname]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass to TopBar:
|
||||||
|
```tsx
|
||||||
|
<TopBar breadcrumb={breadcrumb} ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `BreadcrumbItem` type from `@cameleer/design-system` if needed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Fix sidebar collapse**
|
||||||
|
|
||||||
|
Replace the hardcoded collapse state:
|
||||||
|
```typescript
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Fix username null fallback**
|
||||||
|
|
||||||
|
Update the user prop (line ~125):
|
||||||
|
```tsx
|
||||||
|
const displayName = username || 'User';
|
||||||
|
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
user={{ name: displayName }}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the logout button is always visible.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Replace custom SVG icons with lucide-react**
|
||||||
|
|
||||||
|
Replace the 4 custom SVG icon components (lines 25-62) with lucide-react icons:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LayoutDashboard, ShieldCheck, Building, Server } from 'lucide-react';
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update sidebar sections:
|
||||||
|
```tsx
|
||||||
|
icon={<LayoutDashboard size={18} />} // was <DashboardIcon />
|
||||||
|
icon={<ShieldCheck size={18} />} // was <LicenseIcon />
|
||||||
|
icon={<Building size={18} />} // was <PlatformIcon />
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the 4 custom SVG component functions (DashboardIcon, LicenseIcon, ObsIcon, PlatformIcon).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify**
|
||||||
|
|
||||||
|
1. Sidebar shows active highlight on current page
|
||||||
|
2. Breadcrumbs show "Dashboard", "License", or "Admin > Tenants"
|
||||||
|
3. Sidebar collapse works (click collapse button, sidebar minimizes)
|
||||||
|
4. User avatar/logout always visible
|
||||||
|
5. Icons render correctly from lucide-react
|
||||||
|
6. Check if server controls are hidden (depending on step 1 result)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/components/Layout.tsx ui/src/main.tsx
|
||||||
|
git commit -m "fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Error handling and OrgResolver fix
|
||||||
|
|
||||||
|
**Spec items:** 3.1, 3.2, 3.7
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/auth/OrgResolver.tsx`
|
||||||
|
- Modify: `ui/src/pages/DashboardPage.tsx`
|
||||||
|
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix OrgResolver error state**
|
||||||
|
|
||||||
|
In `ui/src/auth/OrgResolver.tsx`, find the error handling (lines 88-90):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BEFORE:
|
||||||
|
if (isError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||||
|
<EmptyState
|
||||||
|
title="Unable to load account"
|
||||||
|
description="Failed to retrieve your organization. Please try again or contact support."
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add imports: `EmptyState`, `Button` from `@cameleer/design-system`. Ensure `refetch` is available from the query hook (check if `useQuery` returns it).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add error handling to DashboardPage**
|
||||||
|
|
||||||
|
In `ui/src/pages/DashboardPage.tsx`, after the loading check (line ~49) and tenant check (line ~57), add error handling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data: tenant, isError: tenantError } = useTenant();
|
||||||
|
const { data: license, isError: licenseError } = useLicense();
|
||||||
|
|
||||||
|
// After loading spinner check:
|
||||||
|
if (tenantError || licenseError) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
title="Unable to load dashboard"
|
||||||
|
description="Failed to retrieve tenant information. Please try again later."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check how `useTenant()` and `useLicense()` expose error state — they may use `isError` from React Query.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add empty state and date formatting to AdminTenantsPage**
|
||||||
|
|
||||||
|
In `ui/src/pages/AdminTenantsPage.tsx`:
|
||||||
|
|
||||||
|
1. Add error handling:
|
||||||
|
```tsx
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
title="Unable to load tenants"
|
||||||
|
description="You may not have admin permissions, or the server is unavailable."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Format the `createdAt` column (line 31):
|
||||||
|
```tsx
|
||||||
|
// BEFORE:
|
||||||
|
{ key: 'createdAt', header: 'Created' },
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
{ key: 'createdAt', header: 'Created', render: (_, row) => new Date(row.createdAt).toLocaleDateString() },
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add empty state to DataTable (if supported) or show EmptyState when tenants is empty:
|
||||||
|
```tsx
|
||||||
|
{(!tenants || tenants.length === 0) ? (
|
||||||
|
<EmptyState title="No tenants" description="No tenants have been created yet." />
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={tenants} onRowClick={handleRowClick} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/auth/OrgResolver.tsx ui/src/pages/DashboardPage.tsx ui/src/pages/AdminTenantsPage.tsx
|
||||||
|
git commit -m "fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: DS component adoption and license token copy
|
||||||
|
|
||||||
|
**Spec items:** 3.3, 3.4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/LicensePage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace raw button with DS Button**
|
||||||
|
|
||||||
|
In `ui/src/pages/LicensePage.tsx`, find the token toggle button (lines ~166-172):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BEFORE:
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
|
||||||
|
onClick={() => setTokenExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
|
||||||
|
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `Button` is imported from `@cameleer/design-system`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add copy-to-clipboard button**
|
||||||
|
|
||||||
|
Add `useToast` import and `Copy` icon:
|
||||||
|
```typescript
|
||||||
|
import { useToast } from '@cameleer/design-system';
|
||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add toast hook in component:
|
||||||
|
```typescript
|
||||||
|
const { toast } = useToast();
|
||||||
|
```
|
||||||
|
|
||||||
|
Next to the show/hide button, add a copy button (only when expanded):
|
||||||
|
```tsx
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
|
||||||
|
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||||
|
</Button>
|
||||||
|
{tokenExpanded && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => {
|
||||||
|
navigator.clipboard.writeText(license.token);
|
||||||
|
toast({ title: 'Token copied to clipboard', variant: 'success' });
|
||||||
|
}}>
|
||||||
|
<Copy size={14} /> Copy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/pages/LicensePage.tsx
|
||||||
|
git commit -m "fix: replace raw button with DS Button, add token copy-to-clipboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Sign-in page improvements
|
||||||
|
|
||||||
|
**Spec items:** 3.6, 4.5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/sign-in/src/SignInPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add password visibility toggle**
|
||||||
|
|
||||||
|
In `ui/sign-in/src/SignInPage.tsx`, add state and imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the password FormField (lines ~84-94):
|
||||||
|
```tsx
|
||||||
|
<FormField label="Password" htmlFor="login-password">
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
|
||||||
|
padding: 4, display: 'flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Using a raw `<button>` here because the sign-in page may not have the full DS Button available (it's a separate Vite build). Use inline styles for positioning since the sign-in page uses CSS modules.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix branding text**
|
||||||
|
|
||||||
|
In `ui/sign-in/src/SignInPage.tsx`, find the logo text (line ~61):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BEFORE:
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||||
|
cameleer
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||||
|
Cameleer
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the page title if it's set anywhere (check `index.html` in `ui/sign-in/`):
|
||||||
|
```html
|
||||||
|
<title>Sign in — Cameleer</title>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/index.html
|
||||||
|
git commit -m "fix: add password visibility toggle and fix branding to 'Cameleer'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Unify tier colors and fix badges
|
||||||
|
|
||||||
|
**Spec items:** 4.1, 4.2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/src/utils/tier.ts`
|
||||||
|
- Modify: `ui/src/pages/DashboardPage.tsx`
|
||||||
|
- Modify: `ui/src/pages/LicensePage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create shared tier utility**
|
||||||
|
|
||||||
|
Create `ui/src/utils/tier.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto';
|
||||||
|
|
||||||
|
export function tierColor(tier: string): TierColor {
|
||||||
|
switch (tier?.toUpperCase()) {
|
||||||
|
case 'BUSINESS':
|
||||||
|
case 'ENTERPRISE':
|
||||||
|
return 'success';
|
||||||
|
case 'HIGH':
|
||||||
|
case 'PRO':
|
||||||
|
return 'primary';
|
||||||
|
case 'MID':
|
||||||
|
case 'STARTER':
|
||||||
|
return 'warning';
|
||||||
|
case 'LOW':
|
||||||
|
case 'FREE':
|
||||||
|
return 'auto';
|
||||||
|
default:
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace local tierColor in both pages**
|
||||||
|
|
||||||
|
In `DashboardPage.tsx`, remove the local `tierColor` function (lines 12-19) and add:
|
||||||
|
```typescript
|
||||||
|
import { tierColor } from '../utils/tier';
|
||||||
|
```
|
||||||
|
|
||||||
|
In `LicensePage.tsx`, remove the local `tierColor` function (lines 25-33) and add:
|
||||||
|
```typescript
|
||||||
|
import { tierColor } from '../utils/tier';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix feature badge color**
|
||||||
|
|
||||||
|
In `LicensePage.tsx`, find the feature badge (line ~131-132):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BEFORE:
|
||||||
|
color={enabled ? 'success' : 'auto'}
|
||||||
|
|
||||||
|
// Check what neutral badge colors the DS supports.
|
||||||
|
// If 'auto' hashes to inconsistent colors, use a fixed muted option.
|
||||||
|
// AFTER:
|
||||||
|
color={enabled ? 'success' : 'warning'}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `'warning'` (amber/muted) for "Not included" — it's neutral without implying error. If the DS has a better neutral option, use that.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/utils/tier.ts ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx
|
||||||
|
git commit -m "fix: unify tier color mapping, fix feature badge colors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: AdminTenantsPage confirmation and polish
|
||||||
|
|
||||||
|
**Spec items:** 4.3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add confirmation before tenant context switch**
|
||||||
|
|
||||||
|
In `ui/src/pages/AdminTenantsPage.tsx`, add state and import:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AlertDialog } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the row click handler:
|
||||||
|
```tsx
|
||||||
|
// BEFORE:
|
||||||
|
const handleRowClick = (tenant: TenantResponse) => {
|
||||||
|
const orgs = useOrgStore.getState().organizations;
|
||||||
|
const match = orgs.find((o) => o.name === tenant.name || o.slug === tenant.slug);
|
||||||
|
if (match) {
|
||||||
|
setCurrentOrg(match.id);
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
const handleRowClick = (tenant: TenantResponse) => {
|
||||||
|
setSwitchTarget(tenant);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSwitch = () => {
|
||||||
|
if (!switchTarget) return;
|
||||||
|
const orgs = useOrgStore.getState().organizations;
|
||||||
|
const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug);
|
||||||
|
if (match) {
|
||||||
|
setCurrentOrg(match.id);
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
setSwitchTarget(null);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the AlertDialog at the bottom of the component return:
|
||||||
|
```tsx
|
||||||
|
<AlertDialog
|
||||||
|
open={!!switchTarget}
|
||||||
|
onCancel={() => setSwitchTarget(null)}
|
||||||
|
onConfirm={confirmSwitch}
|
||||||
|
title="Switch tenant?"
|
||||||
|
description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`}
|
||||||
|
confirmLabel="Switch"
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/pages/AdminTenantsPage.tsx
|
||||||
|
git commit -m "fix: add confirmation dialog before tenant context switch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Task | Batch | Key Changes | Commit |
|
||||||
|
|------|-------|-------------|--------|
|
||||||
|
| 1 | Layout | CSS module with DS variables, fix label/value, replace text-white | `fix: replace hardcoded text-white with DS variables, fix label/value layout` |
|
||||||
|
| 2 | Layout | Remove redundant "Open Server Dashboard" button | `fix: remove redundant Open Server Dashboard button` |
|
||||||
|
| 3 | Navigation | Sidebar active state, breadcrumbs, collapse, username fallback, lucide icons | `fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons` |
|
||||||
|
| 4 | Error Handling | OrgResolver error UI, DashboardPage error state, AdminTenantsPage error + date format | `fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage` |
|
||||||
|
| 5 | Components | DS Button for token toggle, copy-to-clipboard with toast | `fix: replace raw button with DS Button, add token copy-to-clipboard` |
|
||||||
|
| 6 | Sign-in | Password visibility toggle, branding fix to "Cameleer" | `fix: add password visibility toggle and fix branding to 'Cameleer'` |
|
||||||
|
| 7 | Polish | Shared tierColor(), fix feature badge colors | `fix: unify tier color mapping, fix feature badge colors` |
|
||||||
|
| 8 | Polish | Confirmation dialog for admin tenant switch | `fix: add confirmation dialog before tenant context switch` |
|
||||||
210
docs/superpowers/plans/2026-04-10-fleet-health-plan.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Fleet Health at a Glance Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add agent count, environment count, and agent limit columns to the vendor tenant list so the vendor can see fleet utilization at a glance.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing `VendorTenantSummary` record with three int fields. The list endpoint fetches counts from each active tenant's server via existing M2M API methods (`getAgentCount`, `getEnvironmentCount`), parallelized with `CompletableFuture`. Frontend adds two columns (Agents, Envs) to the DataTable.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot, CompletableFuture, React, TypeScript, @cameleer/design-system DataTable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extend backend — VendorTenantSummary + parallel fetch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the VendorTenantSummary record**
|
||||||
|
|
||||||
|
In `VendorTenantController.java`, replace the record at lines 39-48:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record VendorTenantSummary(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
String slug,
|
||||||
|
String tier,
|
||||||
|
String status,
|
||||||
|
String serverState,
|
||||||
|
String licenseExpiry,
|
||||||
|
String provisionError,
|
||||||
|
int agentCount,
|
||||||
|
int environmentCount,
|
||||||
|
int agentLimit
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the listAll() endpoint to fetch counts in parallel**
|
||||||
|
|
||||||
|
Replace the `listAll()` method at lines 60-77:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<VendorTenantSummary>> listAll() {
|
||||||
|
var tenants = vendorTenantService.listAll();
|
||||||
|
|
||||||
|
// Parallel health fetch for active tenants
|
||||||
|
var futures = tenants.stream().map(tenant -> java.util.concurrent.CompletableFuture.supplyAsync(() -> {
|
||||||
|
ServerStatus status = vendorTenantService.getServerStatus(tenant);
|
||||||
|
String licenseExpiry = vendorTenantService
|
||||||
|
.getLicenseForTenant(tenant.getId())
|
||||||
|
.map(l -> l.getExpiresAt() != null ? l.getExpiresAt().toString() : null)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
int agentCount = 0;
|
||||||
|
int environmentCount = 0;
|
||||||
|
int agentLimit = -1;
|
||||||
|
|
||||||
|
String endpoint = tenant.getServerEndpoint();
|
||||||
|
boolean isActive = "ACTIVE".equals(tenant.getStatus().name());
|
||||||
|
if (isActive && endpoint != null && !endpoint.isBlank() && "RUNNING".equals(status.state().name())) {
|
||||||
|
var serverApi = vendorTenantService.getServerApiClient();
|
||||||
|
agentCount = serverApi.getAgentCount(endpoint);
|
||||||
|
environmentCount = serverApi.getEnvironmentCount(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var license = vendorTenantService.getLicenseForTenant(tenant.getId());
|
||||||
|
if (license.isPresent() && license.get().getLimits() != null) {
|
||||||
|
var limits = license.get().getLimits();
|
||||||
|
if (limits.containsKey("agents")) {
|
||||||
|
agentLimit = ((Number) limits.get("agents")).intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VendorTenantSummary(
|
||||||
|
tenant.getId(), tenant.getName(), tenant.getSlug(),
|
||||||
|
tenant.getTier().name(), tenant.getStatus().name(),
|
||||||
|
status.state().name(), licenseExpiry, tenant.getProvisionError(),
|
||||||
|
agentCount, environmentCount, agentLimit
|
||||||
|
);
|
||||||
|
})).toList();
|
||||||
|
|
||||||
|
List<VendorTenantSummary> summaries = futures.stream()
|
||||||
|
.map(java.util.concurrent.CompletableFuture::join)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(summaries);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Expose ServerApiClient from VendorTenantService**
|
||||||
|
|
||||||
|
Add a getter in `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public ServerApiClient getServerApiClient() {
|
||||||
|
return serverApiClient;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `serverApiClient` field already exists in VendorTenantService — check around line 30.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify compilation**
|
||||||
|
|
||||||
|
Run: `./mvnw compile -pl . -q`
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
|
||||||
|
git commit -m "feat: add agent/env counts to vendor tenant list endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update frontend types and columns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/types/api.ts`
|
||||||
|
- Modify: `ui/src/pages/vendor/VendorTenantsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add fields to VendorTenantSummary TypeScript type**
|
||||||
|
|
||||||
|
In `ui/src/types/api.ts`, update the `VendorTenantSummary` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface VendorTenantSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier: string;
|
||||||
|
status: string;
|
||||||
|
serverState: string;
|
||||||
|
licenseExpiry: string | null;
|
||||||
|
provisionError: string | null;
|
||||||
|
agentCount: number;
|
||||||
|
environmentCount: number;
|
||||||
|
agentLimit: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Agents and Envs columns to VendorTenantsPage**
|
||||||
|
|
||||||
|
In `ui/src/pages/vendor/VendorTenantsPage.tsx`, add a helper function after `statusColor`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function formatUsage(used: number, limit: number): string {
|
||||||
|
return limit < 0 ? `${used} / ∞` : `${used} / ${limit}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add two column entries in the `columns` array, after the `serverState` column (after line 54) and before the `licenseExpiry` column:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
key: 'agentCount',
|
||||||
|
header: 'Agents',
|
||||||
|
render: (_v, row) => (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||||
|
{formatUsage(row.agentCount, row.agentLimit)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'environmentCount',
|
||||||
|
header: 'Envs',
|
||||||
|
render: (_v, row) => (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||||
|
{row.environmentCount}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the UI**
|
||||||
|
|
||||||
|
Run: `cd ui && npm run build`
|
||||||
|
Expected: Build succeeds with no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/types/api.ts ui/src/pages/vendor/VendorTenantsPage.tsx
|
||||||
|
git commit -m "feat: show agent/env counts in vendor tenant list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Verify end-to-end
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run backend tests**
|
||||||
|
|
||||||
|
Run: `./mvnw test -pl . -q`
|
||||||
|
Expected: All tests pass. (Existing tests use mocks, the new parallel fetch doesn't break them since it only affects the controller's list mapping.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Navigate to the vendor tenant list. Confirm:
|
||||||
|
- "Agents" column shows "0 / ∞" (or actual count if agents are connected)
|
||||||
|
- "Envs" column shows "1" (or actual count)
|
||||||
|
- PROVISIONING/SUSPENDED tenants show "0" for both
|
||||||
|
- 30s auto-refresh still works
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
2662
docs/superpowers/plans/2026-04-13-install-script-plan.md
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
# Externalize Docker Compose Templates — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace inline docker-compose generation in installer scripts with static template files, reducing duplication and enabling user customization.
|
||||||
|
|
||||||
|
**Architecture:** Static YAML templates in `installer/templates/` are copied to the install directory. The installer writes `.env` (including `COMPOSE_FILE` to select which templates are active) and runs `docker compose up -d`. Conditional features (TLS, monitoring) are handled via compose file layering and `.env` variables instead of heredoc injection.
|
||||||
|
|
||||||
|
**Tech Stack:** Docker Compose v2, YAML, Bash, PowerShell
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-15-externalize-compose-templates-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create `docker-compose.yml` (infra base template)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `installer/templates/docker-compose.yml`
|
||||||
|
|
||||||
|
This is the shared infrastructure base — always loaded regardless of deployment mode.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the infra base template**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Cameleer Infrastructure
|
||||||
|
# Shared base — always loaded. Mode-specific services in separate compose files.
|
||||||
|
|
||||||
|
services:
|
||||||
|
cameleer-traefik:
|
||||||
|
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-80}:80"
|
||||||
|
- "${HTTPS_PORT:-443}:443"
|
||||||
|
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
|
||||||
|
environment:
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||||
|
CERT_FILE: ${CERT_FILE:-}
|
||||||
|
KEY_FILE: ${KEY_FILE:-}
|
||||||
|
CA_FILE: ${CA_FILE:-}
|
||||||
|
volumes:
|
||||||
|
- cameleer-certs:/certs
|
||||||
|
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
|
||||||
|
labels:
|
||||||
|
- "prometheus.io/scrape=true"
|
||||||
|
- "prometheus.io/port=8082"
|
||||||
|
- "prometheus.io/path=/metrics"
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
- cameleer-traefik
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
cameleer-postgres:
|
||||||
|
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
|
||||||
|
volumes:
|
||||||
|
- cameleer-pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer_saas}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
cameleer-clickhouse:
|
||||||
|
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD must be set in .env}
|
||||||
|
volumes:
|
||||||
|
- cameleer-chdata:/var/lib/clickhouse
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
labels:
|
||||||
|
- "prometheus.io/scrape=true"
|
||||||
|
- "prometheus.io/port=9363"
|
||||||
|
- "prometheus.io/path=/metrics"
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cameleer-pgdata:
|
||||||
|
cameleer-chdata:
|
||||||
|
cameleer-certs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cameleer:
|
||||||
|
driver: bridge
|
||||||
|
cameleer-traefik:
|
||||||
|
name: cameleer-traefik
|
||||||
|
driver: bridge
|
||||||
|
monitoring:
|
||||||
|
name: cameleer-monitoring-noop
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes from the generated version:
|
||||||
|
- Logto console port always present with `LOGTO_CONSOLE_BIND` controlling exposure
|
||||||
|
- Prometheus labels unconditional on traefik and clickhouse
|
||||||
|
- `monitoring` network defined as local noop bridge
|
||||||
|
- All services join `monitoring` network
|
||||||
|
- `POSTGRES_DB` uses `${POSTGRES_DB:-cameleer_saas}` (parameterized — standalone overrides via `.env`)
|
||||||
|
- Password variables use `:?` fail-if-unset
|
||||||
|
|
||||||
|
Note: The SaaS mode uses `cameleer-postgres` (custom multi-DB image) while standalone uses `postgres:16-alpine`. The `POSTGRES_IMAGE` variable already handles this — the infra base uses `${POSTGRES_IMAGE:-...}` and standalone `.env` sets `POSTGRES_IMAGE=postgres:16-alpine`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify YAML is valid**
|
||||||
|
|
||||||
|
Run: `python -c "import yaml; yaml.safe_load(open('installer/templates/docker-compose.yml'))"`
|
||||||
|
Expected: No output (valid YAML). If python/yaml not available, use `docker compose -f installer/templates/docker-compose.yml config --quiet` (will fail on unset vars, but validates structure).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/templates/docker-compose.yml
|
||||||
|
git commit -m "feat(installer): add infra base docker-compose template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create `docker-compose.saas.yml` (SaaS mode template)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `installer/templates/docker-compose.saas.yml`
|
||||||
|
|
||||||
|
SaaS-specific services: Logto identity provider and cameleer-saas management plane.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the SaaS template**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Cameleer SaaS — Logto + management plane
|
||||||
|
# Loaded in SaaS deployment mode
|
||||||
|
|
||||||
|
services:
|
||||||
|
cameleer-logto:
|
||||||
|
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
cameleer-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
|
||||||
|
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||||
|
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||||||
|
TRUST_PROXY_HEADER: 1
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
||||||
|
LOGTO_ENDPOINT: http://cameleer-logto:3001
|
||||||
|
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
|
||||||
|
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||||
|
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||||
|
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||||
|
PG_HOST: cameleer-postgres
|
||||||
|
PG_USER: ${POSTGRES_USER:-cameleer}
|
||||||
|
PG_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
PG_DB_SAAS: cameleer_saas
|
||||||
|
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||||||
|
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 60
|
||||||
|
start_period: 30s
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
|
||||||
|
- traefik.http.routers.cameleer-logto.priority=1
|
||||||
|
- traefik.http.routers.cameleer-logto.entrypoints=websecure
|
||||||
|
- traefik.http.routers.cameleer-logto.tls=true
|
||||||
|
- traefik.http.routers.cameleer-logto.service=cameleer-logto
|
||||||
|
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
|
||||||
|
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
||||||
|
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
||||||
|
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
|
||||||
|
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
|
||||||
|
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
|
||||||
|
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
|
||||||
|
- traefik.http.routers.cameleer-logto-console.tls=true
|
||||||
|
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
|
||||||
|
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
|
||||||
|
volumes:
|
||||||
|
- cameleer-bootstrapdata:/data
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
cameleer-saas:
|
||||||
|
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
cameleer-logto:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# SaaS database
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer_saas
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
# Identity (Logto)
|
||||||
|
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
|
||||||
|
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||||
|
# Provisioning — passed to per-tenant server containers
|
||||||
|
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
||||||
|
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||||
|
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
|
||||||
|
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
|
||||||
|
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
|
||||||
|
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD}
|
||||||
|
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer-server:latest}
|
||||||
|
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
||||||
|
- traefik.http.routers.saas.entrypoints=websecure
|
||||||
|
- traefik.http.routers.saas.tls=true
|
||||||
|
- traefik.http.services.saas.loadbalancer.server.port=8080
|
||||||
|
- "prometheus.io/scrape=true"
|
||||||
|
- "prometheus.io/port=8080"
|
||||||
|
- "prometheus.io/path=/platform/actuator/prometheus"
|
||||||
|
volumes:
|
||||||
|
- cameleer-bootstrapdata:/data/bootstrap:ro
|
||||||
|
- cameleer-certs:/certs
|
||||||
|
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cameleer-bootstrapdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
monitoring:
|
||||||
|
name: cameleer-monitoring-noop
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- Logto console traefik labels always included (harmless when port is localhost-only)
|
||||||
|
- Prometheus labels on cameleer-saas always included
|
||||||
|
- `DOCKER_GID` read from `.env` via `${DOCKER_GID:-0}` instead of inline `stat`
|
||||||
|
- Both services join `monitoring` network
|
||||||
|
- `monitoring` network redefined as noop bridge (compose merges with base definition)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/templates/docker-compose.saas.yml
|
||||||
|
git commit -m "feat(installer): add SaaS docker-compose template"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `docker-compose.server.yml` (standalone mode template)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `installer/templates/docker-compose.server.yml`
|
||||||
|
- Create: `installer/templates/traefik-dynamic.yml`
|
||||||
|
|
||||||
|
Standalone-specific services: cameleer-server + server-ui. Also includes the traefik dynamic config that standalone mode needs (overrides the baked-in SaaS redirect).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the standalone template**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Cameleer Server (standalone)
|
||||||
|
# Loaded in standalone deployment mode
|
||||||
|
|
||||||
|
services:
|
||||||
|
cameleer-traefik:
|
||||||
|
volumes:
|
||||||
|
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
|
||||||
|
cameleer-postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-cameleer}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer}"]
|
||||||
|
|
||||||
|
cameleer-server:
|
||||||
|
image: ${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
|
||||||
|
container_name: cameleer-server
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
cameleer-postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
CAMELEER_SERVER_TENANT_ID: default
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer}?currentSchema=tenant_default
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer
|
||||||
|
CAMELEER_SERVER_CLICKHOUSE_USERNAME: default
|
||||||
|
CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
|
||||||
|
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: ${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN must be set in .env}
|
||||||
|
CAMELEER_SERVER_SECURITY_UIUSER: ${SERVER_ADMIN_USER:-admin}
|
||||||
|
CAMELEER_SERVER_SECURITY_UIPASSWORD: ${SERVER_ADMIN_PASS:?SERVER_ADMIN_PASS must be set in .env}
|
||||||
|
CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||||
|
CAMELEER_SERVER_RUNTIME_ENABLED: "true"
|
||||||
|
CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081
|
||||||
|
CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: ${PUBLIC_HOST:-localhost}
|
||||||
|
CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path
|
||||||
|
CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars
|
||||||
|
CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps
|
||||||
|
CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars
|
||||||
|
CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.server-api.rule=PathPrefix(`/api`)
|
||||||
|
- traefik.http.routers.server-api.entrypoints=websecure
|
||||||
|
- traefik.http.routers.server-api.tls=true
|
||||||
|
- traefik.http.services.server-api.loadbalancer.server.port=8081
|
||||||
|
- traefik.docker.network=cameleer-traefik
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
volumes:
|
||||||
|
- jars:/data/jars
|
||||||
|
- cameleer-certs:/certs:ro
|
||||||
|
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
|
networks:
|
||||||
|
- cameleer
|
||||||
|
- cameleer-traefik
|
||||||
|
- cameleer-apps
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
cameleer-server-ui:
|
||||||
|
image: ${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui}:${VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
cameleer-server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
CAMELEER_API_URL: http://cameleer-server:8081
|
||||||
|
BASE_PATH: ""
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.ui.rule=PathPrefix(`/`)
|
||||||
|
- traefik.http.routers.ui.priority=1
|
||||||
|
- traefik.http.routers.ui.entrypoints=websecure
|
||||||
|
- traefik.http.routers.ui.tls=true
|
||||||
|
- traefik.http.services.ui.loadbalancer.server.port=80
|
||||||
|
- traefik.docker.network=cameleer-traefik
|
||||||
|
networks:
|
||||||
|
- cameleer-traefik
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jars:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cameleer-apps:
|
||||||
|
name: cameleer-apps
|
||||||
|
driver: bridge
|
||||||
|
monitoring:
|
||||||
|
name: cameleer-monitoring-noop
|
||||||
|
```
|
||||||
|
|
||||||
|
Key design decisions:
|
||||||
|
- `cameleer-traefik` and `cameleer-postgres` entries are **overrides** — compose merges them with the base. The postgres image switches to `postgres:16-alpine` and the healthcheck uses `${POSTGRES_DB:-cameleer}` instead of hardcoded `cameleer_saas`. Traefik gets the `traefik-dynamic.yml` volume mount.
|
||||||
|
- `DOCKER_GID` from `.env` via `${DOCKER_GID:-0}`
|
||||||
|
- `BOOTSTRAP_TOKEN` uses `:?` fail-if-unset
|
||||||
|
- Both server and server-ui join `monitoring` network
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the traefik dynamic config template**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tls:
|
||||||
|
stores:
|
||||||
|
default:
|
||||||
|
defaultCertificate:
|
||||||
|
certFile: /certs/cert.pem
|
||||||
|
keyFile: /certs/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is only relevant in standalone mode (overrides the baked-in SaaS `/` -> `/platform/` redirect in the traefik image).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/templates/docker-compose.server.yml installer/templates/traefik-dynamic.yml
|
||||||
|
git commit -m "feat(installer): add standalone docker-compose and traefik templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Create overlay templates (TLS + monitoring)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `installer/templates/docker-compose.tls.yml`
|
||||||
|
- Create: `installer/templates/docker-compose.monitoring.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the TLS overlay**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Custom TLS certificates overlay
|
||||||
|
# Adds user-supplied certificate volume to traefik
|
||||||
|
|
||||||
|
services:
|
||||||
|
cameleer-traefik:
|
||||||
|
volumes:
|
||||||
|
- ./certs:/user-certs:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the monitoring overlay**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# External monitoring network overlay
|
||||||
|
# Overrides the noop monitoring bridge with a real external network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
monitoring:
|
||||||
|
external: true
|
||||||
|
name: ${MONITORING_NETWORK:?MONITORING_NETWORK must be set in .env}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the key to the monitoring pattern: the base compose files define `monitoring` as a local noop bridge and all services join it. When this overlay is included in `COMPOSE_FILE`, compose merges the network definition — overriding it to point at the real external monitoring network. No per-service entries needed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/templates/docker-compose.tls.yml installer/templates/docker-compose.monitoring.yml
|
||||||
|
git commit -m "feat(installer): add TLS and monitoring overlay templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create `.env.example`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `installer/templates/.env.example`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the documented variable reference**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cameleer Configuration
|
||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
# The installer generates .env automatically — this file is for reference.
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Compose file assembly (set by installer)
|
||||||
|
# ============================================================
|
||||||
|
# SaaS: docker-compose.yml:docker-compose.saas.yml
|
||||||
|
# Standalone: docker-compose.yml:docker-compose.server.yml
|
||||||
|
# Add :docker-compose.tls.yml for custom TLS certificates
|
||||||
|
# Add :docker-compose.monitoring.yml for external monitoring network
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Image version
|
||||||
|
# ============================================================
|
||||||
|
VERSION=latest
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Public access
|
||||||
|
# ============================================================
|
||||||
|
PUBLIC_HOST=localhost
|
||||||
|
PUBLIC_PROTOCOL=https
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Ports
|
||||||
|
# ============================================================
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
# Set to 0.0.0.0 to expose Logto admin console externally (default: localhost only)
|
||||||
|
# LOGTO_CONSOLE_BIND=0.0.0.0
|
||||||
|
LOGTO_CONSOLE_PORT=3002
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PostgreSQL
|
||||||
|
# ============================================================
|
||||||
|
POSTGRES_USER=cameleer
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
# SaaS: cameleer_saas, Standalone: cameleer
|
||||||
|
POSTGRES_DB=cameleer_saas
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ClickHouse
|
||||||
|
# ============================================================
|
||||||
|
CLICKHOUSE_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Admin credentials (SaaS mode)
|
||||||
|
# ============================================================
|
||||||
|
SAAS_ADMIN_USER=admin
|
||||||
|
SAAS_ADMIN_PASS=CHANGE_ME
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Admin credentials (standalone mode)
|
||||||
|
# ============================================================
|
||||||
|
# SERVER_ADMIN_USER=admin
|
||||||
|
# SERVER_ADMIN_PASS=CHANGE_ME
|
||||||
|
# BOOTSTRAP_TOKEN=CHANGE_ME
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TLS
|
||||||
|
# ============================================================
|
||||||
|
# Set to 1 to reject unauthorized TLS certificates (production)
|
||||||
|
NODE_TLS_REJECT=0
|
||||||
|
# Custom TLS certificate paths (inside container, set by installer)
|
||||||
|
# CERT_FILE=/user-certs/cert.pem
|
||||||
|
# KEY_FILE=/user-certs/key.pem
|
||||||
|
# CA_FILE=/user-certs/ca.pem
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Docker
|
||||||
|
# ============================================================
|
||||||
|
DOCKER_SOCKET=/var/run/docker.sock
|
||||||
|
# GID of the docker socket — detected by installer, used for container group_add
|
||||||
|
DOCKER_GID=0
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Provisioning images (SaaS mode only)
|
||||||
|
# ============================================================
|
||||||
|
# CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
||||||
|
# CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Monitoring (optional)
|
||||||
|
# ============================================================
|
||||||
|
# External Docker network name for Prometheus scraping.
|
||||||
|
# Only needed when docker-compose.monitoring.yml is in COMPOSE_FILE.
|
||||||
|
# MONITORING_NETWORK=prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/templates/.env.example
|
||||||
|
git commit -m "feat(installer): add .env.example with documented variables"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update `install.sh` — replace compose generation with template copying
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `installer/install.sh:574-672` (generate_env_file — add COMPOSE_FILE and LOGTO_CONSOLE_BIND)
|
||||||
|
- Modify: `installer/install.sh:674-1135` (replace generate_compose_file + generate_compose_file_standalone with copy_templates)
|
||||||
|
- Modify: `installer/install.sh:1728-1731` (reinstall cleanup — delete template files)
|
||||||
|
- Modify: `installer/install.sh:1696-1710` (upgrade path — copy templates instead of generate)
|
||||||
|
- Modify: `installer/install.sh:1790-1791` (main — call copy_templates instead of generate_compose_file)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `generate_compose_file` and `generate_compose_file_standalone` with `copy_templates`**
|
||||||
|
|
||||||
|
Delete both functions (`generate_compose_file` at line 674 and `generate_compose_file_standalone` at line 934) and replace with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy_templates() {
|
||||||
|
local src
|
||||||
|
src="$(cd "$(dirname "$0")" && pwd)/templates"
|
||||||
|
|
||||||
|
# Base infra — always copied
|
||||||
|
cp "$src/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml"
|
||||||
|
cp "$src/.env.example" "$INSTALL_DIR/.env.example"
|
||||||
|
|
||||||
|
# Mode-specific
|
||||||
|
if [ "$DEPLOYMENT_MODE" = "standalone" ]; then
|
||||||
|
cp "$src/docker-compose.server.yml" "$INSTALL_DIR/docker-compose.server.yml"
|
||||||
|
cp "$src/traefik-dynamic.yml" "$INSTALL_DIR/traefik-dynamic.yml"
|
||||||
|
else
|
||||||
|
cp "$src/docker-compose.saas.yml" "$INSTALL_DIR/docker-compose.saas.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional overlays
|
||||||
|
if [ "$TLS_MODE" = "custom" ]; then
|
||||||
|
cp "$src/docker-compose.tls.yml" "$INSTALL_DIR/docker-compose.tls.yml"
|
||||||
|
fi
|
||||||
|
if [ -n "$MONITORING_NETWORK" ]; then
|
||||||
|
cp "$src/docker-compose.monitoring.yml" "$INSTALL_DIR/docker-compose.monitoring.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Copied docker-compose templates to $INSTALL_DIR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `generate_env_file` to include `COMPOSE_FILE`, `LOGTO_CONSOLE_BIND`, and `DOCKER_GID`**
|
||||||
|
|
||||||
|
In the standalone `.env` block (line 577-614), add after the `DOCKER_GID` line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compose file assembly
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
In the SaaS `.env` block (line 617-668), add `LOGTO_CONSOLE_BIND` and `COMPOSE_FILE`. After the `LOGTO_CONSOLE_PORT` line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOGTO_CONSOLE_BIND=$([ "$LOGTO_CONSOLE_EXPOSED" = "true" ] && echo "0.0.0.0" || echo "127.0.0.1")
|
||||||
|
```
|
||||||
|
|
||||||
|
And at the end of the SaaS block, add the `COMPOSE_FILE` line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compose file assembly
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add the `MONITORING_NETWORK` variable to `.env` when set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if [ -n "$MONITORING_NETWORK" ]; then
|
||||||
|
echo "" >> "$f"
|
||||||
|
echo "# Monitoring" >> "$f"
|
||||||
|
echo "MONITORING_NETWORK=${MONITORING_NETWORK}" >> "$f"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `main()` — replace `generate_compose_file` call with `copy_templates`**
|
||||||
|
|
||||||
|
At line 1791, change:
|
||||||
|
```bash
|
||||||
|
generate_compose_file
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```bash
|
||||||
|
copy_templates
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `handle_rerun` upgrade path**
|
||||||
|
|
||||||
|
At line 1703, change:
|
||||||
|
```bash
|
||||||
|
generate_compose_file
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```bash
|
||||||
|
copy_templates
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update reinstall cleanup to remove template files**
|
||||||
|
|
||||||
|
At lines 1728-1731, update the `rm -f` list to include all possible template files:
|
||||||
|
```bash
|
||||||
|
rm -f "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.bak" "$INSTALL_DIR/.env.example" \
|
||||||
|
"$INSTALL_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.saas.yml" \
|
||||||
|
"$INSTALL_DIR/docker-compose.server.yml" "$INSTALL_DIR/docker-compose.tls.yml" \
|
||||||
|
"$INSTALL_DIR/docker-compose.monitoring.yml" "$INSTALL_DIR/traefik-dynamic.yml" \
|
||||||
|
"$INSTALL_DIR/cameleer.conf" "$INSTALL_DIR/credentials.txt" \
|
||||||
|
"$INSTALL_DIR/INSTALL.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/install.sh
|
||||||
|
git commit -m "refactor(installer): replace sh compose generation with template copying"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update `install.ps1` — replace compose generation with template copying
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `installer/install.ps1:574-666` (Generate-EnvFile — add COMPOSE_FILE and LOGTO_CONSOLE_BIND)
|
||||||
|
- Modify: `installer/install.ps1:671-1105` (replace Generate-ComposeFile + Generate-ComposeFileStandalone with Copy-Templates)
|
||||||
|
- Modify: `installer/install.ps1:1706-1723` (upgrade path)
|
||||||
|
- Modify: `installer/install.ps1:1746` (reinstall cleanup)
|
||||||
|
- Modify: `installer/install.ps1:1797-1798` (Main — call Copy-Templates)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `Generate-ComposeFile` and `Generate-ComposeFileStandalone` with `Copy-Templates`**
|
||||||
|
|
||||||
|
Delete both functions and replace with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
function Copy-Templates {
|
||||||
|
$c = $script:cfg
|
||||||
|
$src = Join-Path $PSScriptRoot 'templates'
|
||||||
|
|
||||||
|
# Base infra — always copied
|
||||||
|
Copy-Item (Join-Path $src 'docker-compose.yml') (Join-Path $c.InstallDir 'docker-compose.yml') -Force
|
||||||
|
Copy-Item (Join-Path $src '.env.example') (Join-Path $c.InstallDir '.env.example') -Force
|
||||||
|
|
||||||
|
# Mode-specific
|
||||||
|
if ($c.DeploymentMode -eq 'standalone') {
|
||||||
|
Copy-Item (Join-Path $src 'docker-compose.server.yml') (Join-Path $c.InstallDir 'docker-compose.server.yml') -Force
|
||||||
|
Copy-Item (Join-Path $src 'traefik-dynamic.yml') (Join-Path $c.InstallDir 'traefik-dynamic.yml') -Force
|
||||||
|
} else {
|
||||||
|
Copy-Item (Join-Path $src 'docker-compose.saas.yml') (Join-Path $c.InstallDir 'docker-compose.saas.yml') -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional overlays
|
||||||
|
if ($c.TlsMode -eq 'custom') {
|
||||||
|
Copy-Item (Join-Path $src 'docker-compose.tls.yml') (Join-Path $c.InstallDir 'docker-compose.tls.yml') -Force
|
||||||
|
}
|
||||||
|
if ($c.MonitoringNetwork) {
|
||||||
|
Copy-Item (Join-Path $src 'docker-compose.monitoring.yml') (Join-Path $c.InstallDir 'docker-compose.monitoring.yml') -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Log-Info "Copied docker-compose templates to $($c.InstallDir)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `Generate-EnvFile` to include `COMPOSE_FILE`, `LOGTO_CONSOLE_BIND`, and `MONITORING_NETWORK`**
|
||||||
|
|
||||||
|
In the standalone `.env` content block, add after `DOCKER_GID`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$composeFile = 'docker-compose.yml:docker-compose.server.yml'
|
||||||
|
if ($c.TlsMode -eq 'custom') { $composeFile += ':docker-compose.tls.yml' }
|
||||||
|
if ($c.MonitoringNetwork) { $composeFile += ':docker-compose.monitoring.yml' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Then append to `$content`:
|
||||||
|
```powershell
|
||||||
|
$content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile"
|
||||||
|
if ($c.MonitoringNetwork) {
|
||||||
|
$content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the SaaS `.env` content block, add `LOGTO_CONSOLE_BIND` after `LOGTO_CONSOLE_PORT`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$consoleBind = if ($c.LogtoConsoleExposed -eq 'true') { '0.0.0.0' } else { '127.0.0.1' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the content string: `LOGTO_CONSOLE_BIND=$consoleBind`
|
||||||
|
|
||||||
|
Build `COMPOSE_FILE`:
|
||||||
|
```powershell
|
||||||
|
$composeFile = 'docker-compose.yml:docker-compose.saas.yml'
|
||||||
|
if ($c.TlsMode -eq 'custom') { $composeFile += ':docker-compose.tls.yml' }
|
||||||
|
if ($c.MonitoringNetwork) { $composeFile += ':docker-compose.monitoring.yml' }
|
||||||
|
```
|
||||||
|
|
||||||
|
And append to `$content`:
|
||||||
|
```powershell
|
||||||
|
$content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile"
|
||||||
|
if ($c.MonitoringNetwork) {
|
||||||
|
$content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `Main` — replace `Generate-ComposeFile` call with `Copy-Templates`**
|
||||||
|
|
||||||
|
At line 1798, change:
|
||||||
|
```powershell
|
||||||
|
Generate-ComposeFile
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```powershell
|
||||||
|
Copy-Templates
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `Handle-Rerun` upgrade path**
|
||||||
|
|
||||||
|
At line 1716, change:
|
||||||
|
```powershell
|
||||||
|
Generate-ComposeFile
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```powershell
|
||||||
|
Copy-Templates
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update reinstall cleanup to remove template files**
|
||||||
|
|
||||||
|
At line 1746, update the filename list:
|
||||||
|
```powershell
|
||||||
|
foreach ($fname in @('.env','.env.bak','.env.example','docker-compose.yml','docker-compose.saas.yml','docker-compose.server.yml','docker-compose.tls.yml','docker-compose.monitoring.yml','traefik-dynamic.yml','cameleer.conf','credentials.txt','INSTALL.md')) {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add installer/install.ps1
|
||||||
|
git commit -m "refactor(installer): replace ps1 compose generation with template copying"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update existing generated install and clean up
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `installer/cameleer/docker-compose.yml` (replace with template copy for dev environment)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the old generated docker-compose.yml from the cameleer/ directory**
|
||||||
|
|
||||||
|
The `installer/cameleer/` directory contains a previously generated install. The `docker-compose.yml` there is now stale — it was generated by the old inline method. Since this is a dev environment output, remove it (it will be recreated by running the installer with the new template approach).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm installer/cameleer/docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `installer/cameleer/` to `.gitignore` if not already there**
|
||||||
|
|
||||||
|
The install output directory should not be tracked. Check if `.gitignore` already covers it. If not, add:
|
||||||
|
|
||||||
|
```
|
||||||
|
installer/cameleer/
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents generated `.env`, `credentials.txt`, and compose files from being committed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A installer/cameleer/ .gitignore
|
||||||
|
git commit -m "chore(installer): remove generated install output, add to gitignore"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Verify the templates produce equivalent output
|
||||||
|
|
||||||
|
**Files:** (no changes — verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Compare template output against the old generated compose**
|
||||||
|
|
||||||
|
Create a temporary `.env` file and run `docker compose config` to render the resolved compose. Compare against the old generated output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd installer/cameleer
|
||||||
|
# Back up old generated file for comparison
|
||||||
|
cp docker-compose.yml docker-compose.old.yml 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create a test .env that exercises the SaaS path
|
||||||
|
cat > /tmp/test-saas.env << 'EOF'
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
|
||||||
|
VERSION=latest
|
||||||
|
PUBLIC_HOST=test.example.com
|
||||||
|
PUBLIC_PROTOCOL=https
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
LOGTO_CONSOLE_PORT=3002
|
||||||
|
LOGTO_CONSOLE_BIND=0.0.0.0
|
||||||
|
POSTGRES_USER=cameleer
|
||||||
|
POSTGRES_PASSWORD=testpass
|
||||||
|
POSTGRES_DB=cameleer_saas
|
||||||
|
CLICKHOUSE_PASSWORD=testpass
|
||||||
|
SAAS_ADMIN_USER=admin
|
||||||
|
SAAS_ADMIN_PASS=testpass
|
||||||
|
NODE_TLS_REJECT=0
|
||||||
|
DOCKER_SOCKET=/var/run/docker.sock
|
||||||
|
DOCKER_GID=0
|
||||||
|
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
||||||
|
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Render the new templates
|
||||||
|
cd ../templates
|
||||||
|
docker compose --env-file /tmp/test-saas.env config
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A fully resolved compose with all 5 services (traefik, postgres, clickhouse, logto, saas), correct environment variables, and the monitoring noop network.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test standalone mode rendering**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /tmp/test-standalone.env << 'EOF'
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
|
||||||
|
VERSION=latest
|
||||||
|
PUBLIC_HOST=test.example.com
|
||||||
|
PUBLIC_PROTOCOL=https
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
POSTGRES_IMAGE=postgres:16-alpine
|
||||||
|
POSTGRES_USER=cameleer
|
||||||
|
POSTGRES_PASSWORD=testpass
|
||||||
|
POSTGRES_DB=cameleer
|
||||||
|
CLICKHOUSE_PASSWORD=testpass
|
||||||
|
SERVER_ADMIN_USER=admin
|
||||||
|
SERVER_ADMIN_PASS=testpass
|
||||||
|
BOOTSTRAP_TOKEN=testtoken
|
||||||
|
DOCKER_SOCKET=/var/run/docker.sock
|
||||||
|
DOCKER_GID=0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cd ../templates
|
||||||
|
docker compose --env-file /tmp/test-standalone.env config
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 5 services (traefik, postgres with `postgres:16-alpine` image, clickhouse, server, server-ui). Postgres `POSTGRES_DB` should be `cameleer`. Server should have all env vars resolved.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test with TLS + monitoring overlays**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /tmp/test-full.env << 'EOF'
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml:docker-compose.tls.yml:docker-compose.monitoring.yml
|
||||||
|
VERSION=latest
|
||||||
|
PUBLIC_HOST=test.example.com
|
||||||
|
PUBLIC_PROTOCOL=https
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
LOGTO_CONSOLE_PORT=3002
|
||||||
|
LOGTO_CONSOLE_BIND=0.0.0.0
|
||||||
|
POSTGRES_USER=cameleer
|
||||||
|
POSTGRES_PASSWORD=testpass
|
||||||
|
POSTGRES_DB=cameleer_saas
|
||||||
|
CLICKHOUSE_PASSWORD=testpass
|
||||||
|
SAAS_ADMIN_USER=admin
|
||||||
|
SAAS_ADMIN_PASS=testpass
|
||||||
|
NODE_TLS_REJECT=0
|
||||||
|
DOCKER_SOCKET=/var/run/docker.sock
|
||||||
|
DOCKER_GID=0
|
||||||
|
MONITORING_NETWORK=prometheus
|
||||||
|
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
||||||
|
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cd ../templates
|
||||||
|
docker compose --env-file /tmp/test-full.env config
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Same as SaaS mode but with `./certs:/user-certs:ro` volume on traefik and the `monitoring` network declared as `external: true` with name `prometheus`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clean up temp files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f /tmp/test-saas.env /tmp/test-standalone.env /tmp/test-full.env
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit verification results as a note (optional)**
|
||||||
|
|
||||||
|
No code changes — this task is verification only. If all checks pass, proceed to the final commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Final commit — update CLAUDE.md deployment modes table
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (update Deployment Modes section to reference template files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the deployment modes documentation**
|
||||||
|
|
||||||
|
In the "Deployment Modes (installer)" section of CLAUDE.md, add a note about the template-based approach:
|
||||||
|
|
||||||
|
After the deployment modes table, add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
The installer uses static docker-compose templates in `installer/templates/`. Templates are copied to the install directory and composed via `COMPOSE_FILE` in `.env`:
|
||||||
|
- `docker-compose.yml` — shared infrastructure (traefik, postgres, clickhouse)
|
||||||
|
- `docker-compose.saas.yml` — SaaS mode (logto, cameleer-saas)
|
||||||
|
- `docker-compose.server.yml` — standalone mode (server, server-ui)
|
||||||
|
- `docker-compose.tls.yml` — overlay: custom TLS cert volume
|
||||||
|
- `docker-compose.monitoring.yml` — overlay: external monitoring network
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update CLAUDE.md with template-based installer architecture"
|
||||||
|
```
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
# Per-Tenant PostgreSQL Isolation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Give each tenant its own PostgreSQL user and schema so tenant servers can only access their own data at the database level.
|
||||||
|
|
||||||
|
**Architecture:** During provisioning, create a dedicated PG user (`tenant_<slug>`) with a matching schema. Pass per-tenant credentials and `currentSchema`/`ApplicationName` JDBC parameters to the server container. On delete, drop both schema and user. Existing tenants without `dbPassword` fall back to shared credentials for backwards compatibility.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot 3.4, Flyway, PostgreSQL 16, Docker Java API
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-15-per-tenant-pg-isolation-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Flyway Migration — add `db_password` column
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/db/migration/V015__add_tenant_db_password.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create migration file**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tenants ADD COLUMN db_password VARCHAR(255);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify migration applies**
|
||||||
|
|
||||||
|
Run: `mvn flyway:info -pl .` or start the app and check logs for `V015__add_tenant_db_password` in Flyway output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/db/migration/V015__add_tenant_db_password.sql
|
||||||
|
git commit -m "feat: add db_password column to tenants table (V015)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: TenantEntity — add `dbPassword` field
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add field and accessors**
|
||||||
|
|
||||||
|
After the `provisionError` field (line 59), add:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Column(name = "db_password")
|
||||||
|
private String dbPassword;
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `setProvisionError` method (line 102), add:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public String getDbPassword() { return dbPassword; }
|
||||||
|
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java
|
||||||
|
git commit -m "feat: add dbPassword field to TenantEntity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `TenantDatabaseService`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement the service**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.provisioning;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and drops per-tenant PostgreSQL users and schemas
|
||||||
|
* on the shared cameleer database for DB-level tenant isolation.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class TenantDatabaseService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseService.class);
|
||||||
|
|
||||||
|
private final ProvisioningProperties props;
|
||||||
|
|
||||||
|
public TenantDatabaseService(ProvisioningProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a dedicated PG user and schema for a tenant.
|
||||||
|
* Idempotent — skips if user/schema already exist.
|
||||||
|
*/
|
||||||
|
public void createTenantDatabase(String slug, String password) {
|
||||||
|
validateSlug(slug);
|
||||||
|
|
||||||
|
String url = props.datasourceUrl();
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
log.warn("No datasource URL configured — skipping tenant DB setup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String user = "tenant_" + slug;
|
||||||
|
String schema = "tenant_" + slug;
|
||||||
|
|
||||||
|
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
|
||||||
|
// Create user if not exists
|
||||||
|
boolean userExists;
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT 1 FROM pg_roles WHERE rolname = '" + user + "'")) {
|
||||||
|
userExists = rs.next();
|
||||||
|
}
|
||||||
|
if (!userExists) {
|
||||||
|
stmt.execute("CREATE USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||||
|
log.info("Created PostgreSQL user: {}", user);
|
||||||
|
} else {
|
||||||
|
// Update password on re-provision
|
||||||
|
stmt.execute("ALTER USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||||
|
log.info("Updated password for existing PostgreSQL user: {}", user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create schema if not exists
|
||||||
|
boolean schemaExists;
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT 1 FROM information_schema.schemata WHERE schema_name = '" + schema + "'")) {
|
||||||
|
schemaExists = rs.next();
|
||||||
|
}
|
||||||
|
if (!schemaExists) {
|
||||||
|
stmt.execute("CREATE SCHEMA \"" + schema + "\" AUTHORIZATION \"" + user + "\"");
|
||||||
|
log.info("Created PostgreSQL schema: {}", schema);
|
||||||
|
} else {
|
||||||
|
// Ensure ownership is correct
|
||||||
|
stmt.execute("ALTER SCHEMA \"" + schema + "\" OWNER TO \"" + user + "\"");
|
||||||
|
log.info("Schema {} already exists — ensured ownership", schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke access to public schema
|
||||||
|
stmt.execute("REVOKE ALL ON SCHEMA public FROM \"" + user + "\"");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to create tenant database for '" + slug + "': " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop tenant schema (CASCADE) and user. Idempotent.
|
||||||
|
*/
|
||||||
|
public void dropTenantDatabase(String slug) {
|
||||||
|
validateSlug(slug);
|
||||||
|
|
||||||
|
String url = props.datasourceUrl();
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
log.warn("No datasource URL configured — skipping tenant DB cleanup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String user = "tenant_" + slug;
|
||||||
|
String schema = "tenant_" + slug;
|
||||||
|
|
||||||
|
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
|
||||||
|
log.info("Dropped PostgreSQL schema: {}", schema);
|
||||||
|
|
||||||
|
stmt.execute("DROP USER IF EXISTS \"" + user + "\"");
|
||||||
|
log.info("Dropped PostgreSQL user: {}", user);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to drop tenant database for '{}': {}", slug, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSlug(String slug) {
|
||||||
|
if (slug == null || !slug.matches("^[a-z0-9-]+$")) {
|
||||||
|
throw new IllegalArgumentException("Invalid tenant slug: " + slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapePassword(String password) {
|
||||||
|
return password.replace("'", "''");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java
|
||||||
|
git commit -m "feat: add TenantDatabaseService for per-tenant PG user+schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add `dbPassword` to `TenantProvisionRequest`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add field to record**
|
||||||
|
|
||||||
|
Replace the entire record with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.provisioning;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record TenantProvisionRequest(
|
||||||
|
UUID tenantId,
|
||||||
|
String slug,
|
||||||
|
String tier,
|
||||||
|
String licenseToken,
|
||||||
|
String dbPassword
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java
|
||||||
|
git commit -m "feat: add dbPassword to TenantProvisionRequest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update `DockerTenantProvisioner` — per-tenant JDBC URL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java:197-200`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace shared credentials with per-tenant credentials**
|
||||||
|
|
||||||
|
In `createServerContainer()` (line 197-200), replace:
|
||||||
|
|
||||||
|
```java
|
||||||
|
var env = new java.util.ArrayList<>(List.of(
|
||||||
|
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
|
||||||
|
"SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
|
||||||
|
"SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Per-tenant DB isolation: dedicated user+schema when dbPassword is set,
|
||||||
|
// shared credentials for backwards compatibility with pre-isolation tenants.
|
||||||
|
String dsUrl;
|
||||||
|
String dsUser;
|
||||||
|
String dsPass;
|
||||||
|
if (req.dbPassword() != null) {
|
||||||
|
dsUrl = props.datasourceUrl() + "?currentSchema=tenant_" + slug + "&ApplicationName=tenant_" + slug;
|
||||||
|
dsUser = "tenant_" + slug;
|
||||||
|
dsPass = req.dbPassword();
|
||||||
|
} else {
|
||||||
|
dsUrl = props.datasourceUrl();
|
||||||
|
dsUser = props.datasourceUsername();
|
||||||
|
dsPass = props.datasourcePassword();
|
||||||
|
}
|
||||||
|
var env = new java.util.ArrayList<>(List.of(
|
||||||
|
"SPRING_DATASOURCE_URL=" + dsUrl,
|
||||||
|
"SPRING_DATASOURCE_USERNAME=" + dsUser,
|
||||||
|
"SPRING_DATASOURCE_PASSWORD=" + dsPass,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java
|
||||||
|
git commit -m "feat: construct per-tenant JDBC URL with currentSchema and ApplicationName"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update `VendorTenantService` — provisioning and delete flows
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inject `TenantDatabaseService`**
|
||||||
|
|
||||||
|
Add to the constructor and field declarations:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private final TenantDatabaseService tenantDatabaseService;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the constructor parameter list and assignment. (Follow the existing pattern of other injected services.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `provisionAsync()` — create DB before containers**
|
||||||
|
|
||||||
|
In `provisionAsync()` (around line 120), add DB creation before the provision call. Replace:
|
||||||
|
|
||||||
|
```java
|
||||||
|
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
|
||||||
|
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Create per-tenant PG user + schema
|
||||||
|
String dbPassword = UUID.randomUUID().toString().replace("-", "")
|
||||||
|
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||||
|
try {
|
||||||
|
tenantDatabaseService.createTenantDatabase(slug, dbPassword);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to create tenant database for {}: {}", slug, e.getMessage(), e);
|
||||||
|
tenantRepository.findById(tenantId).ifPresent(t -> {
|
||||||
|
t.setProvisionError("Database setup failed: " + e.getMessage());
|
||||||
|
tenantRepository.save(t);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store DB password on entity
|
||||||
|
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
|
||||||
|
if (tenantForDb == null) {
|
||||||
|
log.error("Tenant {} disappeared during provisioning", slug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tenantForDb.setDbPassword(dbPassword);
|
||||||
|
tenantRepository.save(tenantForDb);
|
||||||
|
|
||||||
|
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
|
||||||
|
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the existing `TenantProvisionRequest` constructor call in upgrade flow**
|
||||||
|
|
||||||
|
Search for any other `new TenantProvisionRequest(...)` calls. The `upgradeServer` method (or re-provision after upgrade) also creates a provision request. Update it to pass `dbPassword` from the entity:
|
||||||
|
|
||||||
|
```java
|
||||||
|
TenantEntity tenant = ...;
|
||||||
|
var provisionRequest = new TenantProvisionRequest(
|
||||||
|
tenant.getId(), tenant.getSlug(), tenant.getTier().name(),
|
||||||
|
licenseToken, tenant.getDbPassword());
|
||||||
|
```
|
||||||
|
|
||||||
|
If the tenant has `dbPassword == null` (pre-existing), this is fine — Task 5 handles the null fallback.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `delete()` — use TenantDatabaseService**
|
||||||
|
|
||||||
|
In `delete()` (around line 306), replace:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Erase tenant data from server databases (GDPR)
|
||||||
|
dataCleanupService.cleanup(tenant.getSlug());
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Drop per-tenant PG schema + user
|
||||||
|
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
|
||||||
|
|
||||||
|
// Erase ClickHouse data (GDPR)
|
||||||
|
dataCleanupService.cleanupClickHouse(tenant.getSlug());
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
|
||||||
|
git commit -m "feat: create per-tenant PG database during provisioning, drop on delete"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Refactor `TenantDataCleanupService` — ClickHouse only
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove PG logic, rename public method**
|
||||||
|
|
||||||
|
Remove the `dropPostgresSchema()` method and the `cleanup()` method. Replace with a single public method:
|
||||||
|
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* Deletes tenant data from ClickHouse tables (GDPR data erasure).
|
||||||
|
* PostgreSQL cleanup is handled by TenantDatabaseService.
|
||||||
|
*/
|
||||||
|
public void cleanupClickHouse(String slug) {
|
||||||
|
deleteClickHouseData(slug);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `dropPostgresSchema()` private method entirely. Keep `deleteClickHouseData()` unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java
|
||||||
|
git commit -m "refactor: move PG cleanup to TenantDatabaseService, keep only ClickHouse"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Verify end-to-end
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn compile -pl .
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify no compilation errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Deploy and test tenant creation**
|
||||||
|
|
||||||
|
Deploy the updated SaaS image. Create a new tenant via the UI. Verify in PostgreSQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Should show the new tenant user
|
||||||
|
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
|
||||||
|
|
||||||
|
-- Should show the new tenant schema
|
||||||
|
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify server container env vars**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect cameleer-server-<slug> | grep -E "DATASOURCE|currentSchema|ApplicationName"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: URL contains `?currentSchema=tenant_<slug>&ApplicationName=tenant_<slug>`, username is `tenant_<slug>`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify Infrastructure page**
|
||||||
|
|
||||||
|
Navigate to Vendor > Infrastructure. The PostgreSQL card should now show the tenant schema with size/tables/rows.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test tenant deletion**
|
||||||
|
|
||||||
|
Delete the tenant. Verify:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- User should be gone
|
||||||
|
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
|
||||||
|
|
||||||
|
-- Schema should be gone
|
||||||
|
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit all remaining changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: per-tenant PostgreSQL isolation — complete implementation"
|
||||||
|
```
|
||||||
1371
docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md
Normal file
449
docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# Email Template Polish Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace inline HTML email templates with polished, branded HTML files loaded from classpath, featuring playful desert/caravan copy, structured card layout with watermark, and proper header/footer.
|
||||||
|
|
||||||
|
**Architecture:** Extract 4 email templates from `EmailConnectorService.buildSmtpConfig()` into standalone HTML files at `src/main/resources/email-templates/`. Generate a pre-faded watermark PNG served as a static asset. Inject `ProvisioningProperties` to resolve the watermark URL at runtime.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot, ImageMagick (one-time asset generation), HTML email (inline styles only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
|
||||||
|
| Action | File | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Create | `src/main/resources/email-templates/register.html` | Registration verification email |
|
||||||
|
| Create | `src/main/resources/email-templates/sign-in.html` | Sign-in verification email |
|
||||||
|
| Create | `src/main/resources/email-templates/forgot-password.html` | Password reset email |
|
||||||
|
| Create | `src/main/resources/email-templates/generic.html` | Generic verification email |
|
||||||
|
| Create | `src/main/resources/static/assets/email-watermark.png` | Pre-faded logo at 7% opacity |
|
||||||
|
| Modify | `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` | Load templates from classpath, inject watermark URL |
|
||||||
|
| Modify | `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49` | Permit `/assets/**` for unauthenticated email clients |
|
||||||
|
| Create | `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java` | Verify templates load and placeholders resolve |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Generate the pre-faded watermark PNG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/static/assets/email-watermark.png`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the faded watermark using ImageMagick**
|
||||||
|
|
||||||
|
Source the logo from the design-system sibling repo. Apply 7% opacity on a transparent background, output to the static assets directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
magick "C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png" \
|
||||||
|
-channel A -evaluate Multiply 0.07 +channel \
|
||||||
|
-resize 320x320 \
|
||||||
|
"src/main/resources/static/assets/email-watermark.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `magick` is not available, use Python Pillow as fallback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open('C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png').convert('RGBA')
|
||||||
|
img = img.resize((320, 320), Image.LANCZOS)
|
||||||
|
r, g, b, a = img.split()
|
||||||
|
a = a.point(lambda x: int(x * 0.07))
|
||||||
|
img = Image.merge('RGBA', (r, g, b, a))
|
||||||
|
img.save('src/main/resources/static/assets/email-watermark.png')
|
||||||
|
print('Saved watermark')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file exists and is reasonable size**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la src/main/resources/static/assets/email-watermark.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: File exists, roughly 5-30 KB.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/static/assets/email-watermark.png
|
||||||
|
git commit -m "feat: add pre-faded logo watermark for email templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Permit static assets in SecurityConfig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49`
|
||||||
|
|
||||||
|
The watermark image must be loadable by email clients without authentication. The current security config has `.anyRequest().authenticated()` as catch-all, so `/assets/**` needs an explicit permit.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `/assets/**` to the permitAll list**
|
||||||
|
|
||||||
|
In `SecurityConfig.java`, find the existing line:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
```
|
||||||
|
|
||||||
|
Change it to:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
|
||||||
|
git commit -m "feat: permit /assets/** for unauthenticated access (email watermark)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create the 4 HTML email template files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/email-templates/register.html`
|
||||||
|
- Create: `src/main/resources/email-templates/sign-in.html`
|
||||||
|
- Create: `src/main/resources/email-templates/forgot-password.html`
|
||||||
|
- Create: `src/main/resources/email-templates/generic.html`
|
||||||
|
|
||||||
|
All templates use the same card structure. The `{{code}}` placeholder is Logto's built-in substitution. The `{{watermarkUrl}}` placeholder is replaced by `EmailConnectorService` at runtime.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `register.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `sign-in.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `forgot-password.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `generic.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/email-templates/
|
||||||
|
git commit -m "feat: add branded HTML email templates with desert/caravan copy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Refactor EmailConnectorService to load templates from classpath
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class EmailTemplateLoadingTest {
|
||||||
|
|
||||||
|
private static final String[] TEMPLATE_FILES = {
|
||||||
|
"email-templates/register.html",
|
||||||
|
"email-templates/sign-in.html",
|
||||||
|
"email-templates/forgot-password.html",
|
||||||
|
"email-templates/generic.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allTemplateFilesExistOnClasspath() {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
var resource = new ClassPathResource(path);
|
||||||
|
assertTrue(resource.exists(), "Template file missing: " + path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainCodePlaceholder() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("{{code}}"),
|
||||||
|
path + " must contain {{code}} placeholder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainWatermarkPlaceholder() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("{{watermarkUrl}}"),
|
||||||
|
path + " must contain {{watermarkUrl}} placeholder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void watermarkPlaceholderIsReplaced() throws IOException {
|
||||||
|
String content = new ClassPathResource("email-templates/register.html")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String resolved = content.replace("{{watermarkUrl}}",
|
||||||
|
"https://example.com/platform/assets/email-watermark.png");
|
||||||
|
assertFalse(resolved.contains("{{watermarkUrl}}"));
|
||||||
|
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainBrandElements() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("Cameleer.io"),
|
||||||
|
path + " must contain Cameleer.io header");
|
||||||
|
assertTrue(content.contains("Apache Camel observability"),
|
||||||
|
path + " must contain tagline");
|
||||||
|
assertTrue(content.contains("#C6820E"),
|
||||||
|
path + " must use brand color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass (templates exist from Task 3)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All 5 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `ProvisioningProperties` dependency to `EmailConnectorService`**
|
||||||
|
|
||||||
|
Replace the constructor and add the template loading logic. The full updated `EmailConnectorService.java`:
|
||||||
|
|
||||||
|
Change the imports and fields at the top of the class — add `ProvisioningProperties` import and field:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the constructor:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
private final ProvisioningProperties provisioningProps;
|
||||||
|
|
||||||
|
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
this.provisioningProps = provisioningProps;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `buildSmtpConfig` method (lines 157-191) with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Load an email template from classpath and resolve the watermark URL placeholder. */
|
||||||
|
private String loadTemplate(String filename) {
|
||||||
|
try {
|
||||||
|
String content = new ClassPathResource("email-templates/" + filename)
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||||
|
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||||
|
return content.replace("{{watermarkUrl}}", watermarkUrl);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to load email template: " + filename, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
|
||||||
|
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
||||||
|
var config = new HashMap<String, Object>();
|
||||||
|
config.put("host", smtp.host());
|
||||||
|
config.put("port", smtp.port());
|
||||||
|
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
|
||||||
|
config.put("fromEmail", smtp.fromEmail());
|
||||||
|
config.put("templates", List.of(
|
||||||
|
Map.of(
|
||||||
|
"usageType", "Register",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your caravan pass is almost ready",
|
||||||
|
"content", loadTemplate("register.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "SignIn",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer sign-in code",
|
||||||
|
"content", loadTemplate("sign-in.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "ForgotPassword",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Reset your Cameleer password",
|
||||||
|
"content", loadTemplate("forgot-password.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "Generic",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer verification code",
|
||||||
|
"content", loadTemplate("generic.html")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the project compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw compile -pl .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the template tests again to confirm nothing broke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All 5 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
|
||||||
|
git add src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
|
||||||
|
git commit -m "feat: load email templates from classpath with watermark URL resolution"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Run the full test suite
|
||||||
|
|
||||||
|
**Files:** None (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -Dspring.profiles.active=test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS, all tests pass. If any existing tests fail due to the new `ProvisioningProperties` constructor parameter on `EmailConnectorService`, they will need their mocks updated — but there are no existing tests for this class.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the watermark is accessible without auth by checking SecurityConfig**
|
||||||
|
|
||||||
|
Confirm the `/assets/**` matcher is in the `permitAll()` chain (done in Task 2). With context-path `/platform`, the full public URL will be `https://<host>/platform/assets/email-watermark.png`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit if any fixes were needed**
|
||||||
|
|
||||||
|
Only if test failures required changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: resolve test failures from email template refactor"
|
||||||
|
```
|
||||||
614
docs/superpowers/plans/2026-04-26-license-minter-integration.md
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
# License Minter Integration — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace UUID-based license tokens with Ed25519-signed tokens minted by `cameleer-license-minter`, with full vendor UI for configurable minting, distribution, and verification.
|
||||||
|
|
||||||
|
**Architecture:** The SaaS platform embeds `cameleer-license-minter` as a Maven dependency and calls `LicenseMinter.mint()` with an Ed25519 private key stored in the DB. Signed tokens are pushed to tenant servers via env vars and REST API. The vendor UI provides tier presets with per-limit customization, copy/email distribution as env-var bundles, and a token verification tool.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.4, JPA/Flyway/PostgreSQL, Ed25519 (JCE), `cameleer-license-minter` + `cameleer-server-core` (LicenseInfo, LicenseValidator), React 19, @cameleer/design-system, TanStack Query.
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
- Tiers renamed: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||||
|
- Tiers are presets only — vendor can customize any limit (becomes "Custom" in UI)
|
||||||
|
- Private key stored in DB (signing_keys table)
|
||||||
|
- Features concept dropped — server enforces caps, not feature flags
|
||||||
|
- Standalone distribution: license bundle = token + public key + tenant ID as env vars
|
||||||
|
- Verify tool: paste token → decode + validate signature → show envelope + state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Backend Foundation
|
||||||
|
|
||||||
|
### Task 1: Maven dependency + Flyway migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pom.xml`
|
||||||
|
- Create: `src/main/resources/db/migration/V002__license_minter.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add minter dependency to pom.xml**
|
||||||
|
|
||||||
|
Add inside `<dependencies>`:
|
||||||
|
```xml
|
||||||
|
<!-- License Minter (Ed25519 signing) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.cameleer</groupId>
|
||||||
|
<artifactId>cameleer-license-minter</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
This transitively brings `cameleer-server-core` (for `LicenseInfo`, `LicenseValidator`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create Flyway V002 migration**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V002: License minter integration
|
||||||
|
-- Signing keys for Ed25519 license minting
|
||||||
|
CREATE TABLE signing_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
public_key_b64 TEXT NOT NULL,
|
||||||
|
private_key_b64 TEXT NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||||
|
UPDATE tenants SET tier = 'STARTER' WHERE tier = 'LOW';
|
||||||
|
UPDATE tenants SET tier = 'TEAM' WHERE tier = 'MID';
|
||||||
|
UPDATE tenants SET tier = 'BUSINESS' WHERE tier = 'HIGH';
|
||||||
|
UPDATE tenants SET tier = 'ENTERPRISE' WHERE tier = 'BUSINESS';
|
||||||
|
-- Fix double-rename: HIGH→BUSINESS rows that got caught by BUSINESS→ENTERPRISE
|
||||||
|
-- Use a single pass via CASE to avoid this:
|
||||||
|
-- Actually, redo with CASE statement in a single UPDATE:
|
||||||
|
|
||||||
|
-- (Replace the 4 UPDATEs above with this single safe statement)
|
||||||
|
UPDATE tenants SET tier = CASE tier
|
||||||
|
WHEN 'LOW' THEN 'STARTER'
|
||||||
|
WHEN 'MID' THEN 'TEAM'
|
||||||
|
WHEN 'HIGH' THEN 'BUSINESS'
|
||||||
|
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||||
|
ELSE tier
|
||||||
|
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||||
|
|
||||||
|
-- Same for licenses table
|
||||||
|
UPDATE licenses SET tier = CASE tier
|
||||||
|
WHEN 'LOW' THEN 'STARTER'
|
||||||
|
WHEN 'MID' THEN 'TEAM'
|
||||||
|
WHEN 'HIGH' THEN 'BUSINESS'
|
||||||
|
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||||
|
ELSE tier
|
||||||
|
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||||
|
|
||||||
|
-- Add new license columns
|
||||||
|
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
|
||||||
|
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Drop features column (server enforces caps, not feature flags)
|
||||||
|
ALTER TABLE licenses DROP COLUMN features;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build compiles**
|
||||||
|
|
||||||
|
Run: `mvn compile -q` (just compile, no tests yet — tests will break until Tier enum is updated)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add cameleer-license-minter dependency and V002 migration
|
||||||
|
|
||||||
|
Adds Ed25519 license minting library, signing_keys table,
|
||||||
|
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
|
||||||
|
adds label + grace_period_days to licenses, drops features column.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Tier enum rename + LicenseDefaults rewrite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update Tier enum**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
|
public enum Tier {
|
||||||
|
STARTER, TEAM, BUSINESS, ENTERPRISE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update TenantEntity default**
|
||||||
|
|
||||||
|
Change `private Tier tier = Tier.LOW;` to `private Tier tier = Tier.STARTER;`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update TenantService fallback**
|
||||||
|
|
||||||
|
Change `Tier.valueOf(request.tier()) : Tier.LOW` to `Tier.valueOf(request.tier()) : Tier.STARTER`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewrite LicenseDefaults**
|
||||||
|
|
||||||
|
Replace entire file with 13-key limits per tier matching the handoff cap matrix. Drop `featuresForTier()`. Only `limitsForTier()`.
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class LicenseDefaults {
|
||||||
|
|
||||||
|
private LicenseDefaults() {}
|
||||||
|
|
||||||
|
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
|
||||||
|
public static final int DEFAULT_LICENSE_DAYS = 365;
|
||||||
|
|
||||||
|
public static Map<String, Integer> limitsForTier(Tier tier) {
|
||||||
|
return switch (tier) {
|
||||||
|
case STARTER -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 2),
|
||||||
|
Map.entry("max_apps", 10),
|
||||||
|
Map.entry("max_agents", 20),
|
||||||
|
Map.entry("max_users", 5),
|
||||||
|
Map.entry("max_outbound_connections", 5),
|
||||||
|
Map.entry("max_alert_rules", 10),
|
||||||
|
Map.entry("max_total_cpu_millis", 8000),
|
||||||
|
Map.entry("max_total_memory_mb", 8192),
|
||||||
|
Map.entry("max_total_replicas", 25),
|
||||||
|
Map.entry("max_execution_retention_days", 7),
|
||||||
|
Map.entry("max_log_retention_days", 7),
|
||||||
|
Map.entry("max_metric_retention_days", 7),
|
||||||
|
Map.entry("max_jar_retention_count", 5)
|
||||||
|
);
|
||||||
|
case TEAM -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 5),
|
||||||
|
Map.entry("max_apps", 50),
|
||||||
|
Map.entry("max_agents", 100),
|
||||||
|
Map.entry("max_users", 25),
|
||||||
|
Map.entry("max_outbound_connections", 25),
|
||||||
|
Map.entry("max_alert_rules", 50),
|
||||||
|
Map.entry("max_total_cpu_millis", 32000),
|
||||||
|
Map.entry("max_total_memory_mb", 32768),
|
||||||
|
Map.entry("max_total_replicas", 100),
|
||||||
|
Map.entry("max_execution_retention_days", 30),
|
||||||
|
Map.entry("max_log_retention_days", 30),
|
||||||
|
Map.entry("max_metric_retention_days", 30),
|
||||||
|
Map.entry("max_jar_retention_count", 10)
|
||||||
|
);
|
||||||
|
case BUSINESS -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 10),
|
||||||
|
Map.entry("max_apps", 200),
|
||||||
|
Map.entry("max_agents", 500),
|
||||||
|
Map.entry("max_users", 100),
|
||||||
|
Map.entry("max_outbound_connections", 100),
|
||||||
|
Map.entry("max_alert_rules", 200),
|
||||||
|
Map.entry("max_total_cpu_millis", 128000),
|
||||||
|
Map.entry("max_total_memory_mb", 131072),
|
||||||
|
Map.entry("max_total_replicas", 500),
|
||||||
|
Map.entry("max_execution_retention_days", 90),
|
||||||
|
Map.entry("max_log_retention_days", 90),
|
||||||
|
Map.entry("max_metric_retention_days", 90),
|
||||||
|
Map.entry("max_jar_retention_count", 25)
|
||||||
|
);
|
||||||
|
case ENTERPRISE -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 50),
|
||||||
|
Map.entry("max_apps", 1000),
|
||||||
|
Map.entry("max_agents", 5000),
|
||||||
|
Map.entry("max_users", 1000),
|
||||||
|
Map.entry("max_outbound_connections", 500),
|
||||||
|
Map.entry("max_alert_rules", 1000),
|
||||||
|
Map.entry("max_total_cpu_millis", 512000),
|
||||||
|
Map.entry("max_total_memory_mb", 524288),
|
||||||
|
Map.entry("max_total_replicas", 2000),
|
||||||
|
Map.entry("max_execution_retention_days", 365),
|
||||||
|
Map.entry("max_log_retention_days", 180),
|
||||||
|
Map.entry("max_metric_retention_days", 180),
|
||||||
|
Map.entry("max_jar_retention_count", 50)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: SigningKeyService + SigningKeyEntity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create SigningKeyEntity**
|
||||||
|
|
||||||
|
JPA entity for the `signing_keys` table: id (UUID), publicKeyB64 (text), privateKeyB64 (text), active (boolean), createdAt (Instant).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create SigningKeyRepository**
|
||||||
|
|
||||||
|
JpaRepository with `Optional<SigningKeyEntity> findByActiveTrue()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create SigningKeyService**
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `getOrCreateActiveKey()` → returns the active key, generating a new Ed25519 keypair on first call
|
||||||
|
- `getPublicKeyBase64()` → convenience for the active key's public key
|
||||||
|
- `getPrivateKey()` → reconstructs `PrivateKey` from stored base64
|
||||||
|
|
||||||
|
Key generation:
|
||||||
|
```java
|
||||||
|
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||||
|
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
|
||||||
|
```
|
||||||
|
|
||||||
|
Private key reconstruction:
|
||||||
|
```java
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(entity.getPrivateKeyB64());
|
||||||
|
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||||
|
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add SigningKeyService for Ed25519 keypair management
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Rewrite LicenseService + LicenseEntity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update LicenseEntity**
|
||||||
|
|
||||||
|
- Remove `features` field + getter/setter
|
||||||
|
- Add `label` (String) field + getter/setter
|
||||||
|
- Add `gracePeriodDays` (int) field + getter/setter
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite LicenseService**
|
||||||
|
|
||||||
|
- Add `SigningKeyService` dependency
|
||||||
|
- Rewrite `generateLicense(TenantEntity, Map<String,Integer> limits, Instant expiresAt, int gracePeriodDays, String label, UUID actorId)`:
|
||||||
|
- Build `LicenseInfo(UUID.randomUUID(), tenant.getSlug(), label, limits, Instant.now(), expiresAt, gracePeriodDays)`
|
||||||
|
- Call `LicenseMinter.mint(info, signingKeyService.getPrivateKey())`
|
||||||
|
- Store signed token in entity
|
||||||
|
- Add convenience overload `generateLicense(TenantEntity, Duration, UUID actorId)` that uses tier presets
|
||||||
|
- Remove `verifyLicenseToken()` (server validates cryptographically)
|
||||||
|
- Add `verifyToken(String token)` that uses `LicenseValidator`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update LicenseResponse DTO**
|
||||||
|
|
||||||
|
Replace `features` with `label` and `gracePeriodDays`. Add `publicKeyB64` for bundle distribution.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: rewrite LicenseService to mint Ed25519-signed tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Update controllers + portal service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update VendorTenantController**
|
||||||
|
|
||||||
|
- `POST /{id}/license` now takes a request body with limits, expiresAt, gracePeriodDays, label
|
||||||
|
- Add `GET /license-presets` endpoint returning tier presets
|
||||||
|
- Add `POST /license/verify` endpoint
|
||||||
|
- Add `GET /signing-key/public` endpoint
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update VendorTenantService**
|
||||||
|
|
||||||
|
- `renewLicense()` updated to accept customizable parameters
|
||||||
|
- Add `mintLicense()` method with full limit configuration
|
||||||
|
- Add `verifyToken()` delegation
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update VendorTenantController response types**
|
||||||
|
|
||||||
|
- `VendorTenantSummary` — fix `agentLimit` to use `max_agents` key
|
||||||
|
- `VendorTenantDetail` — license field uses updated LicenseResponse
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update TenantPortalService**
|
||||||
|
|
||||||
|
- `DashboardData` — drop features, keep limits
|
||||||
|
- `LicenseData` — drop features, add label + gracePeriodDays
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: update vendor/portal APIs for Ed25519 license minting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Fix tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update all Tier.LOW→STARTER, Tier.MID→TEAM, Tier.HIGH→BUSINESS, Tier.BUSINESS→ENTERPRISE**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update LicenseServiceTest**
|
||||||
|
|
||||||
|
- `generateLicense_producesUuidToken` → rename to `generateLicense_producesSignedToken`, assert token contains `.` separator
|
||||||
|
- Remove feature-related assertions
|
||||||
|
- Mock `SigningKeyService` to return a test keypair
|
||||||
|
- Remove `verifyLicenseToken` tests
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update LicenseControllerTest**
|
||||||
|
|
||||||
|
- Remove feature assertions (`features.correlation`)
|
||||||
|
- Update tier values in assertions
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn test -q`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
test: update tests for Ed25519 license minting and tier rename
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2: Provisioning Integration
|
||||||
|
|
||||||
|
### Task 7: Push public key to tenant containers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inject SigningKeyService into DockerTenantProvisioner**
|
||||||
|
|
||||||
|
Add `SigningKeyService` as a constructor dependency.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add CAMELEER_SERVER_LICENSE_PUBLICKEY env var**
|
||||||
|
|
||||||
|
In `createServerContainer()`, after the existing env vars, add:
|
||||||
|
```java
|
||||||
|
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64()
|
||||||
|
```
|
||||||
|
|
||||||
|
`CAMELEER_SERVER_TENANT_ID` is already set to slug (line 218).
|
||||||
|
`CAMELEER_SERVER_LICENSE_TOKEN` is already set (line 225).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: push Ed25519 public key to tenant server containers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 3: Vendor API — Configurable Minting
|
||||||
|
|
||||||
|
### Task 8: Vendor license endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create DTOs**
|
||||||
|
|
||||||
|
`MintLicenseRequest`: tier (optional String), limits (Map<String,Integer>), expiresAt (Instant), gracePeriodDays (Integer), label (String), pushToServer (boolean)
|
||||||
|
|
||||||
|
`VerifyLicenseRequest`: token (String)
|
||||||
|
|
||||||
|
`VerifyLicenseResponse`: valid (boolean), state (String), envelope fields (tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays), error (String)
|
||||||
|
|
||||||
|
`LicensePreset`: tier (String), limits (Map<String,Integer>)
|
||||||
|
|
||||||
|
`LicenseBundleResponse`: extends LicenseResponse + adds publicKeyB64, tenantSlug (for the env-var bundle)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update VendorTenantService**
|
||||||
|
|
||||||
|
Add `mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId)`:
|
||||||
|
- Resolves limits from request (or tier preset)
|
||||||
|
- Calls `licenseService.generateLicense()` with full params
|
||||||
|
- Optionally pushes to server
|
||||||
|
- Returns the license + public key + slug for the bundle
|
||||||
|
|
||||||
|
Add `verifyToken(String token)`:
|
||||||
|
- Uses LicenseValidator from server-core
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update VendorTenantController**
|
||||||
|
|
||||||
|
- `POST /{id}/license` — takes MintLicenseRequest body, returns LicenseBundleResponse
|
||||||
|
- `GET /license-presets` — returns list of LicensePreset
|
||||||
|
- `POST /license/verify` — takes VerifyLicenseRequest, returns VerifyLicenseResponse
|
||||||
|
- `GET /signing-key/public` — returns `{"publicKey": "<base64>"}`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add vendor license minting, presets, and verify endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4: Vendor UI — License Minting
|
||||||
|
|
||||||
|
### Task 9: Update frontend types + hooks
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/types/api.ts`
|
||||||
|
- Modify: `ui/src/api/vendor-hooks.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update types**
|
||||||
|
|
||||||
|
- `LicenseResponse` — remove `features`, add `label`, `gracePeriodDays`, `publicKeyB64`, `tenantSlug`
|
||||||
|
- Add `MintLicenseRequest`, `VerifyLicenseRequest`, `VerifyLicenseResponse`, `LicensePreset`, `LicenseBundleResponse`
|
||||||
|
- `DashboardData` — remove `features`
|
||||||
|
- `TenantLicenseData` — remove `features`, add `label`, `gracePeriodDays`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update hooks**
|
||||||
|
|
||||||
|
- `useRenewLicense()` → replace with `useMintLicense(tenantId)` that takes MintLicenseRequest body
|
||||||
|
- Add `useLicensePresets()`
|
||||||
|
- Add `useVerifyLicense()`
|
||||||
|
- Add `usePublicKey()`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): update types and hooks for Ed25519 license minting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: License minting form on TenantDetailPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/vendor/TenantDetailPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace License card**
|
||||||
|
|
||||||
|
Replace the simple "Renew License" button with a minting form:
|
||||||
|
- Tier preset dropdown (STARTER/TEAM/BUSINESS/ENTERPRISE) that pre-fills limits
|
||||||
|
- All 13 limits editable in a grid
|
||||||
|
- Expiry date picker, grace period input, label input
|
||||||
|
- "Custom" indicator when limits diverge from preset
|
||||||
|
- Actions: "Mint & Push to Server" (default), "Mint & Copy Bundle", "Mint & Email Bundle"
|
||||||
|
|
||||||
|
- [ ] **Step 2: License bundle display**
|
||||||
|
|
||||||
|
After minting, show a dialog/card with the full env-var bundle:
|
||||||
|
```
|
||||||
|
CAMELEER_SERVER_TENANT_ID=<slug>
|
||||||
|
CAMELEER_SERVER_LICENSE_PUBLICKEY=<public_key>
|
||||||
|
CAMELEER_SERVER_LICENSE_TOKEN=<token>
|
||||||
|
```
|
||||||
|
With a "Copy Bundle" button.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): add license minting form with tier presets and bundle distribution
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 11: License verify tool + public key viewer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/src/pages/vendor/LicenseVerifyPage.tsx`
|
||||||
|
- Modify: `ui/src/router.tsx` (add route)
|
||||||
|
- Modify: `ui/src/Layout.tsx` (add nav item)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create LicenseVerifyPage**
|
||||||
|
|
||||||
|
- Textarea to paste a token
|
||||||
|
- "Verify" button
|
||||||
|
- Results: valid/invalid badge, decoded envelope (tenantId, label, limits, expiry, grace period)
|
||||||
|
- State badge (ACTIVE/GRACE/EXPIRED/INVALID)
|
||||||
|
- Public key display section with copy button
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add route and navigation**
|
||||||
|
|
||||||
|
Route: `/vendor/license-verify`
|
||||||
|
Nav: "License Tools" section in vendor sidebar
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): add license verify tool and public key viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 12: Update tier color utility
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/utils/tier.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update tierColor**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
|
||||||
|
switch (tier?.toUpperCase()) {
|
||||||
|
case 'ENTERPRISE': return 'success';
|
||||||
|
case 'BUSINESS': return 'primary';
|
||||||
|
case 'TEAM': return 'running';
|
||||||
|
case 'STARTER': return 'warning';
|
||||||
|
default: return 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(ui): update tier color mapping for renamed tiers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: Tenant UI Updates
|
||||||
|
|
||||||
|
### Task 13: Update TenantLicensePage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/tenant/TenantLicensePage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove features card, update limits card**
|
||||||
|
|
||||||
|
- Drop the "Features" card entirely
|
||||||
|
- Update "Limits & Usage" card to show all 13 limit keys with proper labels
|
||||||
|
- Show grace period and label if present
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): update tenant license page for Ed25519 model
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 14: Update TenantDashboardPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/tenant/TenantDashboardPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove features references**
|
||||||
|
|
||||||
|
Drop any `features` display. Keep limits display.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(ui): remove features from tenant dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 15: Update CreateTenantPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/vendor/CreateTenantPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update tier options**
|
||||||
|
|
||||||
|
Change tier dropdown options from LOW/MID/HIGH/BUSINESS to STARTER/TEAM/BUSINESS/ENTERPRISE.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(ui): update tier options in create tenant form
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After all tasks:
|
||||||
|
- [ ] `mvn test` passes
|
||||||
|
- [ ] `cd ui && npm run build` succeeds
|
||||||
|
- [ ] Docker compose boots (if available)
|
||||||
|
- [ ] Verify a tenant can be created with STARTER tier
|
||||||
|
- [ ] Verify license is minted with Ed25519 signature (token contains `.`)
|
||||||
|
- [ ] Verify CAMELEER_SERVER_LICENSE_PUBLICKEY appears in container env
|
||||||