diff --git a/.gitea/workflows/cd-develop.yml b/.gitea/workflows/cd-develop.yml
new file mode 100644
index 0000000..6502dbc
--- /dev/null
+++ b/.gitea/workflows/cd-develop.yml
@@ -0,0 +1,117 @@
+name: CD - Develop (guide → bcos.dev)
+
+# Guide-specific CI (issue #5). The guide bundles markdown INTO the backend jar at build time,
+# so a running instance must be rebuilt + redeployed to pick up content changes — a git-pull is
+# not enough. This automates that on every push to develop: build → push :dev → deploy to bcos.dev.
+#
+# Auto-deploy is intentional and scoped to the DEV environment only (docs, low blast radius).
+# Audience filtering is runtime (GUIDE_SERVED_AUDIENCES). The app default is `customer` (fail-safe);
+# bcos.dev opts UP to all four tiers explicitly in its compose. Production leaves it unset and so
+# serves customer only — internal/dev/bcos content never leaves dev, even if a config line is missed.
+
+# Auto-deploy live: BCOS_DEV_DEPLOY_KEY provisioned (scoped forced-command key on .251).
+
+on:
+ push:
+ branches:
+ - develop
+ paths:
+ - 'backend/**'
+ - '.gitea/workflows/cd-develop.yml'
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ env:
+ GIT_SSL_NO_VERIFY: "true"
+ MAVEN_OPTS: "-Djava.net.preferIPv4Stack=true -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true"
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure Maven settings (Gitea package registry)
+ run: |
+ mkdir -p ~/.m2
+ cat > ~/.m2/settings.xml << 'EOF'
+
+
+ giteahiveiq${{ secrets.MAVEN_TOKEN }}
+
+
+
+ gitea-registry
+
+
+ gitea
+ https://hiveiq-gitea.directlx.dev/api/packages/hiveops/maven
+ truefalse
+
+
+
+
+ gitea-registry
+
+ EOF
+
+ # mvn package FIRST — the Dockerfile does `COPY target/*.jar`, so skipping this ships stale
+ # content (the exact trap that makes the manual process error-prone).
+ - name: Build backend JAR (bakes in content)
+ run: cd backend && mvn clean package -DskipTests -B
+
+ - name: Build + push image
+ run: |
+ echo "${{ secrets.DEV_REGISTRY_PASSWORD }}" | docker login ${{ secrets.DEV_REGISTRY_URL }} -u ${{ secrets.DEV_REGISTRY_USERNAME }} --password-stdin
+ docker build -t ${{ secrets.DEV_REGISTRY_URL }}/hiveiq-guide-backend:dev backend/
+ docker push ${{ secrets.DEV_REGISTRY_URL }}/hiveiq-guide-backend:dev
+
+ # DEPENDENCY (issue #5, coordinate with CI/runner session): needs BCOS_DEV_DEPLOY_KEY secret
+ # (SSH key for bcosadmin@.251) + runner network reachability to 173.231.195.251.
+ - name: Deploy to bcos.dev
+ run: |
+ mkdir -p ~/.ssh && echo "${{ secrets.BCOS_DEV_DEPLOY_KEY }}" > ~/.ssh/dk && chmod 600 ~/.ssh/dk
+ ssh -i ~/.ssh/dk -o StrictHostKeyChecking=no bcosadmin@173.231.195.251 \
+ 'cd ~/hiveiq/hiveops-openmetal/hiveops/instances/dev && docker compose pull hiveiq-guide-backend && docker compose up -d hiveiq-guide-backend'
+
+ - name: Smoke — guide backend healthy + content served
+ run: |
+ # `compose up -d` restarts the Spring Boot backend, which needs well over 60s to warm
+ # up and report healthy — poll for ~5 min so a normal restart isn't a false failure.
+ for i in $(seq 1 30); do
+ s=$(curl -sk -o /dev/null -w '%{http_code}' https://api.bcos.dev/guide/actuator/health || echo 000)
+ [ "$s" = "200" ] && { echo "guide healthy (attempt $i)"; exit 0; }
+ echo "attempt $i/30: health=$s — waiting 10s"
+ sleep 10
+ done
+ echo "::error::guide did not report healthy 5 min after deploy"; exit 1
+
+ # Posts a ✅/🔴 card to #hiveops-dev-deploy on every run (deploy result). Runs even on
+ # failure so a broken build/deploy/smoke is announced, not silent. SLACK_WEBHOOK_URL is an
+ # org secret; if unset the step no-ops so forks/dry-runs don't error.
+ - name: Notify Slack (#hiveops-dev-deploy)
+ if: always()
+ continue-on-error: true # a notification must NEVER fail the deploy
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ JOB_STATUS: ${{ job.status }}
+ ACTOR: ${{ github.actor }}
+ REF_NAME: ${{ github.ref_name }}
+ SHA: ${{ github.sha }}
+ RUN_NUMBER: ${{ github.run_number }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ COMMIT_MSG: ${{ github.event.head_commit.message }}
+ run: |
+ [ -z "$SLACK_WEBHOOK_URL" ] && { echo "no SLACK_WEBHOOK_URL secret — skipping"; exit 0; }
+ SHORT="${SHA:0:8}"
+ FIRST="${COMMIT_MSG%%$'\n'*}" # first line via param-expansion (no pipe)
+ SUBJECT="$(printf '%s' "$FIRST" | tr -d '"\\' | cut -c1-100)"
+ if [ "$JOB_STATUS" = "success" ]; then
+ COLOR="#2eb67d"; TITLE=":white_check_mark: guide → bcos.dev deployed"
+ else
+ COLOR="#e01e5a"; TITLE=":red_circle: guide → bcos.dev deploy FAILED ($JOB_STATUS)"
+ fi
+ PAYLOAD=$(printf '{"attachments":[{"color":"%s","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*%s*"}},{"type":"section","fields":[{"type":"mrkdwn","text":"*Commit*\\n`%s` %s"},{"type":"mrkdwn","text":"*Branch*\\n%s"},{"type":"mrkdwn","text":"*By*\\n%s"},{"type":"mrkdwn","text":"*Run*\\n#%s"}]},{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"View run"},"url":"%s"}]}]}]}' \
+ "$COLOR" "$TITLE" "$SHORT" "$SUBJECT" "$REF_NAME" "$ACTOR" "$RUN_NUMBER" "$RUN_URL")
+ # Non-fatal: capture the HTTP code, never let curl's exit fail the step.
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL" || echo "curl-error")
+ echo "slack notify HTTP: $code (200 = card posted; anything else = runner could not reach Slack)"
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..f6a79b4
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,48 @@
+# hiveops-guide — Design (approved 2026-06-30)
+
+One knowledge base that (a) teaches customers how each module works and (b) is the
+source-of-truth for module behavior (for admins + Claude). Markdown-authored, served by the
+backend API, read in a tabbed slide-out.
+
+## Decisions (Johannes, 2026-06-30)
+1. Content = **markdown files in git**, still served via **backend API calls** (not bundled in
+ the frontend).
+2. **Start over** — greenfield. No backward-compat with the old `content_json` guides or the
+ `/api/v1/guides/{pageKey}` contract; the Fleet "Guide" buttons get re-wired to the new API.
+3. **Retire the Guide Admin CRUD app.** The guide keeps **internal docs** (technical guides for
+ admins/Claude, `audience: internal`).
+
+## Content model — markdown in the repo
+`content///*.md` — folder per module, **one file per tab**. Frontmatter:
+```yaml
+---
+module: devices.sw-deploy
+title: Software Deployment
+tab: The Data # the tab label in the slide-out
+order: 20 # tab order
+audience: customer # or "internal" (hidden from customers)
+---
+
+```
+Cross-cutting technical guides: `content/technical/*.md` (e.g. fleet→Kafka→devices, service map).
+
+## Backend (Spring Boot service kept; model replaced)
+- Scans `content/**/*.md` at startup (cached), parses frontmatter + body.
+- API (JWT-gated):
+ - `GET /api/v1/guide/nav?audience=` → apps → modules → tabs tree
+ - `GET /api/v1/guide/modules/{module}?audience=` → module + its tabs (markdown bodies)
+- Remove the DB `content_json` model + admin CRUD endpoints. `audience` gates customer vs internal.
+
+## Frontend
+- New **tabbed slide-out reader** (markdown rendered with `marked`); nav + tabs from the API.
+- Retire the Guide Admin CRUD app.
+
+## Consumers
+- Re-wire the Fleet "Guide" buttons (and future in-app guide entry points) to the new API.
+
+## Phases
+1. **Backend** — markdown content model + `nav`/`modules` API; retire DB/CRUD.
+2. **Frontend** — tabbed slide-out reader.
+3. **Content** — author `devices.sw-deploy` + `fleet.tasks` as the pattern (Overview / Data /
+ How-to + an internal Technical tab), then fan out to all modules.
+4. **Technical guides** (internal) — fleet→Kafka→devices, service map, etc.
diff --git a/backend/pom.xml b/backend/pom.xml
index d3bd5f2..d082c04 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -14,7 +14,7 @@
com.hiveops
hiveops-bom
- 1.0.2
+ 1.0.7
@@ -29,10 +29,6 @@
org.springframework.boot
spring-boot-starter-web
-
- org.springframework.boot
- spring-boot-starter-data-jpa
-
org.springframework.boot
spring-boot-starter-security
@@ -46,21 +42,6 @@
spring-boot-starter-actuator
-
- org.postgresql
- postgresql
- runtime
-
-
-
- org.flywaydb
- flyway-core
-
-
- org.flywaydb
- flyway-database-postgresql
-
-
com.hiveops
hiveops-security-common
diff --git a/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java b/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java
index 0828f67..0b95c3e 100644
--- a/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java
+++ b/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java
@@ -35,11 +35,9 @@ public class SecurityConfig {
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/error").permitAll()
- // Consumer: any authenticated customer, user, or admin
- .requestMatchers(HttpMethod.GET, "/api/v1/guides/**")
- .hasAnyRole("USER", "CUSTOMER", "ADMIN", "MSP_ADMIN", "QDS_ADMIN", "BCOS_ADMIN")
- // Admin CRUD — handled by @PreAuthorize on the controller
- .requestMatchers("/api/v1/admin/guides/**").authenticated()
+ // Read-only guide API: any authenticated user (audience gating done in the controller)
+ .requestMatchers(HttpMethod.GET, "/api/v1/guide/**")
+ .hasAnyRole("USER", "CUSTOMER", "ADMIN", "MSP_ADMIN", "BCOS_ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
diff --git a/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java b/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java
new file mode 100644
index 0000000..1a38b14
--- /dev/null
+++ b/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java
@@ -0,0 +1,34 @@
+package com.hiveops.guide.controller;
+
+import com.hiveops.guide.service.GiteaFeedbackService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/** In-guide "report a bug / suggest an enhancement" → creates a Gitea issue. Requires a logged-in user. */
+@RestController
+@RequestMapping("/api/v1/guide/feedback")
+@RequiredArgsConstructor
+public class FeedbackController {
+
+ private final GiteaFeedbackService service;
+
+ public record FeedbackRequest(String type, String area, String title, String description, String module) {}
+
+ @PostMapping
+ public ResponseEntity> submit(@RequestBody FeedbackRequest r, Authentication auth) {
+ String email = auth != null ? auth.getName() : null;
+ String role = auth != null
+ ? auth.getAuthorities().stream().findFirst()
+ .map(a -> a.getAuthority().replaceFirst("^ROLE_", "")).orElse(null)
+ : null;
+ GiteaFeedbackService.Result res = service.create(
+ r.type(), r.area(), r.title(), r.description(), r.module(), email, role);
+ return res.success()
+ ? ResponseEntity.ok(Map.of("number", res.number(), "url", res.url()))
+ : ResponseEntity.badRequest().body(Map.of("error", res.error() == null ? "Failed" : res.error()));
+ }
+}
diff --git a/backend/src/main/java/com/hiveops/guide/controller/GuideController.java b/backend/src/main/java/com/hiveops/guide/controller/GuideController.java
index 747e27b..be29be4 100644
--- a/backend/src/main/java/com/hiveops/guide/controller/GuideController.java
+++ b/backend/src/main/java/com/hiveops/guide/controller/GuideController.java
@@ -1,86 +1,50 @@
package com.hiveops.guide.controller;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.hiveops.guide.entity.Guide;
-import com.hiveops.guide.service.GuideService;
+import com.hiveops.guide.dto.GuideDtos.ModuleView;
+import com.hiveops.guide.dto.GuideDtos.NavApp;
+import com.hiveops.guide.service.MarkdownGuideService;
import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
-import java.util.Map;
-import java.util.Optional;
+/**
+ * Read-only guide API backed by markdown files. Internal-audience content is only
+ * returned to admin roles (MSP_ADMIN / BCOS_ADMIN); everyone else sees customer content.
+ */
@RestController
+@RequestMapping("/api/v1/guide")
@RequiredArgsConstructor
-@Slf4j
public class GuideController {
- private final GuideService guideService;
- private final ObjectMapper objectMapper;
+ private final MarkdownGuideService service;
- // ── Consumer: any authenticated user (called by SPAs via GuidePanel) ─────
-
- @GetMapping("/api/v1/guides/{pageKey}")
- public ResponseEntity