This page lists all major endpoints exposed by the application for manual testing and smoke verification.
| URL | Description | Auth |
|---|---|---|
/ |
Homepage — tutorial navigator (Vue 3 component) | None |
/tutorials/{slug} |
Individual tutorial (HTML served from HANA BLOB via CAP) | None |
/concepts/{slug} |
Knowledge-graph concept landing page (HTML served from HANA BLOB via CAP; 404 if slug not in PublishedConcepts) | None |
/explore/ |
Interactive knowledge-graph visualization (Vue+Sigma.js, CAP-rendered shell with inline graph JSON). Mobile (<768px) falls back to typed-list view. | None |
/app-space |
Event-themed tutorial space (Vue 3 AppSpace component) | None |
/event-display |
Real-time event display dashboard launcher | None |
/missions/{slug} |
Mission detail page (generated by Hugo at build time) | None |
/groups/{slug} |
Group/completion-path detail page | None |
/me/ |
Profile + Recent Activity timeline (public Hugo page; calls authenticated APIs from JS) | None (page) / XSUAA (API calls) |
The QA channel is a separate author-preview surface backed by a dedicated CAP app (tutorials-srv-qa) and HDI container (tutorials-hana-qa). All routes require the Tutorial.Author XSUAA scope.
| URL | Description | Auth |
|---|---|---|
/tutorials-qa/{slug} |
QA-channel tutorial preview (yellow "QA preview" banner injected) | XSUAA + Tutorial.Author |
/tutorials-qa/_nav.json |
Navigation metadata for QA-channel tutorials | XSUAA + Tutorial.Author |
/tutorials-qa/search/... |
QA-channel search (rewrites to /qa/search/...) |
XSUAA + Tutorial.Author |
/qa-search/... |
Direct QA-search alias (rewrites to /search/...) |
XSUAA + Tutorial.Author |
Bootstrap: the role collection "Tutorials Author" must be assigned via BTP cockpit; users must log out / back in for the new scope to appear in their JWT. Until then,
/tutorials-qa/*returns 403.
| URL | Description | Auth |
|---|---|---|
/display-app/ |
Standalone event dashboard (rotating views: Board, Statistics, Leaderboard) | XSUAA |
/display-app/index.html |
Direct entry point | XSUAA |
The display-app is a standalone Vue 3 + Vite application deployed as static files at
approuter/static/display-app/. It connects via Socket.IO on the/ws/displaynamespace for real-time updates. Used on big monitors at SAP events.
All admin routes require the Admin XSUAA scope.
| URL | Description | Auth |
|---|---|---|
/admin-ui/ |
Admin shell — sap.tnt.ToolPage with side navigation |
XSUAA + Admin |
/admin-ui/components/events/ |
Events management (Fiori Elements) | XSUAA + Admin |
/admin-ui/components/missions/ |
Missions management | XSUAA + Admin |
/admin-ui/components/groups/ |
Groups / completion paths management | XSUAA + Admin |
/admin-ui/components/tutorials/ |
Tutorials management | XSUAA + Admin |
/admin-ui/components/tags/ |
Tags management | XSUAA + Admin |
/admin-ui/components/accomplishments/ |
Accomplishments management | XSUAA + Admin |
/admin-ui/components/prizes/ |
Prizes management | XSUAA + Admin |
/admin-ui/components/operations/ |
Operations dashboard | XSUAA + Admin |
/admin-ui/components/accounts/ |
User accounts management | XSUAA + Admin |
/admin-ui/components/changelog/ |
Change tracking log | XSUAA + Admin |
| URL | Description | Auth |
|---|---|---|
/analytics-ui/ |
Vue 3 SPA with entity browser + SQL editor over AnalyticsService |
XSUAA + Admin |
Backed by
AnalyticsServiceat/admin/analytics. SQL tab uses Monaco (lazy-loaded); queries pass throughsrv/lib/analytics-sql-validator.cjs(SELECT-only, allowlisted tables, wrapped withLIMIT 5001). Entity browser is gated by@analytics.exposedannotations on CDS views/entities.
All scanner routes require the MobileApp XSUAA scope.
| URL | Description | Auth |
|---|---|---|
/scanner-ui/ |
Badge Scanner — UI5 implementation (sap.ndc.BarcodeScanner) |
XSUAA + MobileApp |
/scanner-vue/ |
Badge Scanner — Vue 3 implementation (native BarcodeDetector API) |
XSUAA + MobileApp |
Both scanner apps provide the same functionality: scan a contestant badge QR code, display progress stats, and claim prizes. The Vue version is mobile-optimized with camera-first UX and SAP Fundamental Styles. Falls back to manual JSON input on browsers without
BarcodeDetectorsupport.
| URL | Method | Description | Auth |
|---|---|---|---|
/scanner/getContestant(accountNumber='...') |
GET | OData function — contestant stats + prize info | XSUAA |
/scanner/claimPrize(recordId='...') |
GET | OData function — mark prize as CLAIMED | XSUAA |
These are only accessible when hitting the CAP backend directly (http://localhost:4004), not through the AppRouter.
| URL | Description | Auth |
|---|---|---|
http://localhost:4004/ |
CAP index page — lists all services, entities, and links | None |
/api/$api-docs/ |
Swagger UI for DeveloperService | None |
/admin/$api-docs/ |
Swagger UI for AdminService | None |
/admin/analytics/$api-docs/ |
Swagger UI for AnalyticsService | None |
/admin/exports/$api-docs/ |
Swagger UI for ExportsService | None |
/display/$api-docs/ |
Swagger UI for DisplayService | None |
/api/v1/$api-docs/ |
Swagger UI for ConsolidationService | None |
/search/$api-docs/ |
Swagger UI for SearchService | None |
/chat/$api-docs/ |
Swagger UI for ChatService (no entities; ORD-symmetric) | None |
Note: The CAP index page is not visible through the AppRouter because the catch-all route serves Hugo static content at
/. Access it directly athttp://localhost:4004/during development.
When EXPOSE_CAP_UI=true is set on the CAP srv app, these are accessible through the AppRouter with XSUAA + Admin scope:
| URL | Description | Auth |
|---|---|---|
/_dev |
CAP index page (lists all services) | XSUAA + Admin |
/_dev/api/$api-docs/ |
Swagger UI for DeveloperService | XSUAA + Admin |
/_dev/admin/$api-docs/ |
Swagger UI for AdminService | XSUAA + Admin |
/_dev/admin/analytics/$api-docs/ |
Swagger UI for AnalyticsService | XSUAA + Admin |
/_dev/admin/exports/$api-docs/ |
Swagger UI for ExportsService | XSUAA + Admin |
/_dev/display/$api-docs/ |
Swagger UI for DisplayService | XSUAA + Admin |
/_dev/api/v1/$api-docs/ |
Swagger UI for ConsolidationService | XSUAA + Admin |
/_dev/search/$api-docs/ |
Swagger UI for SearchService | XSUAA + Admin |
/_dev/chat/$api-docs/ |
Swagger UI for ChatService | XSUAA + Admin |
Enable:
cf set-env tutorials-srv EXPOSE_CAP_UI true && cf restart tutorials-srvDisable:cf unset-env tutorials-srv EXPOSE_CAP_UI && cf restart tutorials-srvDo NOT set this on production.
| URL | Description | Auth |
|---|---|---|
/api/$metadata |
DeveloperService OData metadata (EDMX) | XSUAA |
/api/ |
DeveloperService — progress tracking, user-facing API | XSUAA |
/admin/$metadata |
AdminService OData metadata | XSUAA + Admin |
/admin/ |
AdminService — full CRUD for events, missions, etc. | XSUAA + Admin |
/admin/analytics/$metadata |
AnalyticsService OData metadata | XSUAA + Admin |
/admin/analytics/ |
AnalyticsService — @analytics.exposed entity surface + runSelectQuery action |
XSUAA + Admin |
/admin/exports/$metadata |
ExportsService OData metadata | XSUAA + Admin |
/admin/exports/ |
ExportsService — exportLegacyData(format) action |
XSUAA + Admin |
/display/$metadata |
DisplayService OData metadata | XSUAA |
/display/ |
DisplayService — read-only data for event dashboards | XSUAA |
/api/v1/$metadata |
ConsolidationService (v1 compat) OData metadata | XSUAA |
/api/v1/ |
ConsolidationService — legacy API compatibility | XSUAA |
/search/$metadata |
SearchService OData metadata | None |
/search/ |
SearchService — tutorial/mission search (word-boundary matching) | None |
/chat/$metadata |
ChatService OData metadata (no entities; ORD-symmetric only) | XSUAA |
| URL | Method | Description | Auth |
|---|---|---|---|
/health |
GET | Liveness check — returns { status: "ok" } |
None |
/health/db |
GET | DB connectivity check (runs SELECT 1 FROM DUMMY) |
None |
/auth/user |
GET | Current user profile (id, email, name) | XSUAA |
/api/qrcode?url=... |
GET | QR code PNG generation | XSUAA |
/api/recommendations |
GET | Personalized "what's next" recommendations (embedding centroid + co-completion blend) | XSUAA |
/build/catalog |
GET | Mission/group/tutorial catalog (JSON, for build pipeline) | None |
/build/co-completions |
GET | Co-completion graph data (used by recommendations) | None |
/build/navigator |
GET | Missions, groups (incl. standalone), tutorial→mission/group mappings, and checkpoint milestones | None |
/build/slug-mapping |
GET | Slug→ID mapping for all missions/groups | None |
/build/repo-catalog |
GET | Slug-keyed DiscoveredTutorial map (third-tier discovery fallback) |
None |
/build/repo-catalog |
POST | Write the discovered-tutorial baseline (CI-as-canonical-writer) | Bearer (CONTENT_API_KEY) |
/build/concepts |
GET | Published Knowledge-Graph concepts (slug, name, description, teaches/requires/requiredBy/relatedTo). Shares buildConceptsPayload with the CAP concept-render pipeline (GET /content/concepts-index list page + the POST /content/publish/render-concepts detail phase, #1327). |
None |
/graph/publishConcept |
POST | Admin action — sets publishedAt + publishedBy on a Concept |
XSUAA + KnowledgeGraph.Admin |
/graph/unpublishConcept |
POST | Admin action — clears publishedAt + publishedBy on a Concept |
XSUAA + KnowledgeGraph.Admin |
/graph/explore-data |
GET | Bulk graph JSON for the /explore/ page (nodes + edges + generatedAt; 5-min LRU cache) |
None |
/graph/path |
GET | Shortest path between two tutorial/concept slugs (?from=<slug>&to=<slug>). 400 on same-slug. Extracted from Phase 2 Joule tool. |
None |
/api/advocates |
GET | Public list of active developer advocates (sorted by lastName, topics + links denormalized, ETag + max-age=60, stale-while-revalidate=600) |
None |
/api/advocates/:slug/photo[?size=thumb] |
GET | Photo bytes (256-WebP default, 64-WebP with ?size=thumb) served from HANA AdvocatePhotos. ETag is the sha256, max-age=86400. 404 when the advocate has no photo or slug is unknown. |
None |
/feedback/submit |
POST | Tutorial feedback form (rate-limited; submitter IP hashed via SUBMISSION_SALT_SECRET) |
None |
/chat/stream |
POST | Joule chat streaming endpoint (Server-Sent Events) | XSUAA |
/a2a |
POST | A2A JSON-RPC 2.0 endpoint (message/send, message/stream, tasks/get, tasks/cancel) for central Joule consumption (#1220). Skill via metadata.skillId; defaults to conversational tutorial-chat. Enable/config via /admin-ui/#joule (ChatSettings a2aEnabled). |
XSUAA + Tutorial.MCP |
/.well-known/agent-card.json |
GET | A2A Agent Card — public discovery document (5 skills, streaming, xsuaa security scheme). Base URL + token URL from /admin-ui/#joule (ChatSettings). |
None |
/.well-known/a2a-instructions.md |
GET | A2A consumption guide (how to authenticate + call) | None |
/api/codecheck |
POST | AI code-check spike (issue #171, gated on ChatSettings.codeCheckEnabled). Body: { tutorialSlug, stepNumber, submittedCode, language? }. Returns { verdict: 'pass'|'partial'|'fail', summary, suggestions[], correctAspects[] }. 503 when flag off; 429 with Retry-After on per-user 30/hr or per-(user,slug,step) 5/5min cap. |
XSUAA |
/author/generateOsVariants |
POST | AI-assisted OS variant generation for the VS Code authoring plugin (issue #173). Body: { sourceMarkdown, sourceOS, targetOSes[], context? }. Returns { variants[], model, tokensUsed, requestId }. 60/hr per author. See spec #173 §5. |
XSUAA + Tutorial.Author |
/admin/embeddings/stats |
GET | Tutorial embedding coverage / drift statistics | XSUAA + Admin |
/api/alerts |
GET | Active alerts with audience=ALL. 60 s cache. | None |
/api/alerts/me |
GET | ALL + AUTHENTICATED + ADMIN (if admin). 30 s private cache. | XSUAA |
/api/homepage/events |
GET | 3-4 upcoming events (merged from DB + events calendar). 60 s cache. | None |
/api/homepage/videos |
GET | @sapdevs video feed (Developer News + recent uploads). 15-min cache; requires YOUTUBE_API_KEY. |
None |
/api/homepage/communityBlogs |
GET | SAP Community blog RSS feed (latest posts). 30-min cache. | None |
/api/homepage/news |
GET | SAP News headlines RSS feed. 30-min cache. | None |
/api/homepage/shelves?verb=<v> |
GET | All active HomepageShelves entries for one verb (LEARN|BUILD|INTEGRATE|OPERATE|AI|CONNECT). 5-min cache. |
None |
/api/homepage/redirectsActive |
GET | Active LegacyRedirects rows (approuter-only consumer; refreshes hourly). |
None |
/api/homepage/recordRedirectHits |
POST | Idempotent batch hit counter for legacy redirects (approuter-only writer). Body: { hits: [{ id, count }] }. |
None |
/build/homepage-shelves |
GET | All HomepageShelves entries keyed by verb (Hugo build-time only; bakes hugo/data/homepage_shelves.json). Payload includes tagline / whyItMatters / authoringStatus (#759). |
None |
/build/verb-definitions |
GET | All VerbDefinitions rows (6 verbs) — labels, icons, taglines, "why it matters" copy for the homepage verb-spine flip tiles (#759). Bakes hugo/data/verb_definitions.json. |
None |
/build/shelf-definitions |
GET | All ShelfDefinitions rows (4 shelf categories: LEARN / START / REFERENCE / COMMUNITY) — taglines + "why it matters" copy for verb sub-page shelf headers (#759). Bakes hugo/data/shelf_definitions.json. |
None |
/build/navigatorreturns a shape withmissions[],groups[](incl. standalone published Groups),tutorialMappings[], andcheckpointMappings[](milestone markers). The 5-minute in-memory cache is automatically invalidated when the Admin UI saves changes to Missions, Groups, or CompletionPath entities — no?nocache=1needed. Implementation: srv/lib/navigator-catalog.js.
/feedback/submitreturns 503 whenSUBMISSION_SALT_SECRETis missing — set it in CI secrets and locally before testing the form. The request body is capped at 8 KB.
| URL | Method | Description | Auth |
|---|---|---|---|
/content/tutorials/{slug} |
GET | Serve tutorial HTML from HANA (ETag, Cache-Control) | None |
/content/hashes |
GET | SHA-256 map of active content ({ slug: hash }) |
None |
/content/nav |
GET | Navigation metadata for published tutorials | None |
/content/publish |
POST | Deprecated — single-shot publish (base64-gzipped files). Kept for one release cycle; new clients use the chunked protocol below. | Bearer (CONTENT_API_KEY) |
/content/publish/begin |
POST | Open a chunked publish session. Returns { sessionId, version, expiresAt } (201) or 409 if a publish is already in progress. |
Bearer (CONTENT_API_KEY) |
/content/publish/append |
POST | Append a batch of files to an open session. Files shape: { slug: base64gzip }. Idempotent for (sessionId, slug). |
Bearer (CONTENT_API_KEY) |
/content/publish/commit |
POST | Activate the session's manifest. Idempotent (returns alreadyActive: true on repeat). |
Bearer (CONTENT_API_KEY) |
/content/publish/abort |
POST | Discard an open session. Idempotent. | Bearer (CONTENT_API_KEY) |
/content/code-check-specs |
POST | AI code-check spike (issue #171). Upserts CodeCheckSpecs rows from *.codecheck.json sidecars produced by fetch-tutorials. Body: { specs: [{ slug, stepNumber, goal, language?, hints?, referenceSolution? }] }. Carry-forward semantics (no DELETE on absent specs). Server-only — referenceSolution never reaches the client. |
Bearer (CONTENT_API_KEY) |
/content/rollback |
POST | Revert to previous manifest version | Bearer (CONTENT_API_KEY) |
The chunked endpoints replace single-shot POST /content/publish. They split a publish across many small batches so a flaky TCP connection or a 53 MB JSON body doesn't kill the whole run, and the server's commit step does carry-forward of unchanged slugs (so a partial payload no longer drops the rest of the catalog).
Typical client flow:
# 1. Begin a session — server returns sessionId + version
curl -sX POST http://localhost:4004/content/publish/begin \
-H "Authorization: Bearer $CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "trigger": "manual", "hugoVersion": "0.x" }'
# → 201 { "sessionId": "...", "version": 42, "expiresAt": "..." }
# 2. Append batches (50 slugs at a time by default; idempotent per (sessionId, slug))
curl -sX POST http://localhost:4004/content/publish/append \
-H "Authorization: Bearer $CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "sessionId": "...", "files": { "slug-a": "<base64gzip>", "slug-b": "<base64gzip>" } }'
# 3. Commit — activates the manifest; idempotent (returns alreadyActive: true on repeat)
curl -sX POST http://localhost:4004/content/publish/commit \
-H "Authorization: Bearer $CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "sessionId": "..." }'
# Or, abandon the session:
curl -sX POST http://localhost:4004/content/publish/abort \
-H "Authorization: Bearer $CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "sessionId": "..." }'begin returns 409 if another publish session is already in progress; abort it (or wait for the reaper, which sweeps stale sessions every 5 minutes after a 30-minute idle threshold) before retrying.
The CAP backend serves a hosted Model Context Protocol surface via the @cap-js/mcp adapter (tools-only) plus a custom compose router (srv/lib/mcp-compose-router.js) that adds resources + prompts on the RP-bearing mounts. Each CDS service is mounted separately at /mcp/<svc> — there is no aggregate /mcp root. End-user connection guide: docs/end-users/mcp-quickstart.md; parameter reference: docs/developers/reference/mcp-server.md.
| Mount | Service | Curated tools | Auth (at mount) |
|---|---|---|---|
/mcp/search |
SearchService | search_tutorials, list_missions, get_mission, get_tutorial |
None (anonymous) |
/mcp/graph |
KnowledgeGraphService | kg_shared_concepts, kg_neighborhood, kg_search_concepts, kg_community (+ resources + prompts) |
None (anonymous) |
/mcp/homepage |
HomepageService | get_my_recommended_tutorials, get_my_recommended_missions |
authenticated-user |
/mcp/api |
DeveloperService | get_my_tutorials, get_my_missions, get_my_events, get_my_completed_steps, get_tutorial_step, complete_step, reset_tutorial_progress |
authenticated-user |
/mcp/admin |
AdminService | merge_concepts, promote_community_to_mission, trigger_rebuild, publish_content (+ resources + prompts) |
Admin + per-action scope |
describe and query are auto-generated on every mount by @cap-js/mcp. Each service also serves resources (tutorial://<slug>, mission://<slug>, concept://<id>) and prompts (prompts/list) where the compose router is mounted (/mcp/graph, /mcp/admin).
The approuter (approuter/xs-app.json) fronts three additional prefixes that srv/server.js rewrites onto the real /mcp/* mounts:
| Public prefix | Approuter auth | Rewrites to | Purpose |
|---|---|---|---|
/mcp/* |
none (csrf off) | (direct) | Anonymous read tier — public services |
/mcp-pat/* |
none (csrf off) | /mcp/* |
PAT tier — requires Bearer pat_... (else JSON-RPC 401) |
/mcp-auth/* |
xsuaa, scope Tutorial.MCP |
/mcp/* |
OAuth/JWT tier — forwards the user JWT |
/mcp-admin/* |
xsuaa, scope Tutorial.MCP |
/mcp/admin/* |
Phase 3 admin-curation tools |
Kill switches: MCP_AUTH_ENABLED=false → /mcp-auth/* + /mcp-pat/* return 503. MCP_PHASE3_ENABLED=false or MCP_ADMIN_TOOLS_ENABLED=false → /mcp-admin/* returns 503. Transport is Streamable HTTP (MCP protocol 2025-06); clients negotiate JSON vs SSE via Accept.
| URL | Method | Description | Auth |
|---|---|---|---|
/pats |
GET | PatService — list the caller's own PATs (MyPATs, row-scoped to user.email = $user.id) |
XSUAA (authenticated-user) |
/pats/mintPAT |
POST | Mint a PAT: mintPAT(name, scopes, ttlDays) → { ID, token, prefix, expiresAt }. Plaintext token returned once; server stores only a SHA-256 hash. Scopes: read (read tools) / write (allows complete_step, reset_tutorial_progress). |
XSUAA (authenticated-user) |
/pats/MyPATs(<ID>)/PatService.revokePAT |
POST | Bound action — revoke one of the caller's PATs | XSUAA (authenticated-user) |
PATs are recognized by srv/lib/mcp-pat-middleware.js (SHA-256 hash lookup, 60 s cache) on the /mcp-pat/* prefix. Minting is surfaced in the Admin UI at /admin-ui/#pats for users in the Tutorials MCP Users role collection. An expired or revoked PAT returns 401. Write tools reject PATs lacking the write scope with 403.
| URL | Protocol | Description | Auth |
|---|---|---|---|
/ws/event-stream |
Socket.IO | Anonymous live tutorial-completion stream (backed by EventStreamService) |
None |
/ws/display |
Socket.IO | Display dashboard live updates (backed by DisplayService) |
XSUAA DisplayApp (enforced at namespace join, not approuter) |
/socket.io/ |
Socket.IO | Underlying transport endpoint for both namespaces (?EIO=4&transport=websocket) |
None |
Tools registered with the Joule chat runtime (LLM tool-calling surface, not standalone HTTP endpoints). Reachable via POST /chat/stream. Each tool's registration is gated on a ChatSettings flag so the LLM only sees the tool when the feature is enabled.
Author-aware branch + skip recommender for tutorials and missions (issue #172 PR 4).
-
Registration gate — registered when
ChatSettings.enabled = true && ChatSettings.branchingEnabled = true. WhenbranchingEnabled = false, the tool is not registered and the LLM falls back to general guidance. -
Params —
missionSlug?(string),tutorialSlug?(string),branchPointId?(string). At least one ofmissionSlug/tutorialSlugis required.branchPointIdrequirestutorialSlug. -
Return shape:
-
Telemetry — writes one
BranchDecisionsrow per branch / alt-group / skip recommendation withsource: 'jouleTool'. Skip-point telemetry is only emitted whenskip === true. -
Error envelopes (validation failures, no telemetry):
error: 'requires_at_least_one_of: missionSlug, tutorialSlug, branchPointId'error: 'branchPointId requires tutorialSlug'error: 'unknown_branch_point: <id>'
-
Empty-shape envelopes (not errors, no recommendation possible):
note: 'tutorial_has_no_branches'note: 'mission_not_found'note: 'mission_has_no_alt_groups'
Implementation: srv/lib/branch/joule-tool.js. Telemetry helper: srv/lib/branch/branch-telemetry.js.
Hybrid pathBetween Joule tool — translates "I want to learn X" / "what should I learn next" / "show me a path to Y" prompts into an ordered tutorial sequence (issue #445 / Phase 2 of #381).
-
Registration gate — registered when
ChatSettings.enabled = true && ChatSettings.kgPathBetweenEnabled = true. WhenkgPathBetweenEnabled = false(default), the tool is not registered and the LLM falls back to general guidance viasearchTutorials. -
Params —
toSlug(string, required, lowercase alphanumeric + hyphens, 1-80 chars),fromSlug?(string, optional — same shape). IffromSlugis omitted, the handler infers it from the user's most-recent COMPLETED TaskRecord; if no completion history, the search is unanchored (usestoSlugas its own neighborhood center). -
Return shape — rendered markdown string that the LLM paraphrases or quotes verbatim:
Here's a path from `<fromSlug>` to `<toSlug>`: 1. **<title>** — [<slug>](https://developers.sap.com/tutorials/<slug>.html) ~<minutes> min · <reason>
Where
<reason>is one of"Prerequisite chain","Often completed together","Shares concepts". Empty result set returns a friendly "couldn't find a path" message pointing at the catalog. -
Telemetry — emits
kg.joule.path_requestedat dispatch start ({ fromSlug, toSlug, hasUserId, fromSlugInferred, unanchored }) andkg.joule.path_returnedat dispatch end ({ ..., resultCount, pathTypeBreakdown: { PREREQ, CO_COMPLETED, SHARED_CONCEPT }, latencyMs, exactTargetReached, error? }). ThepathTypeBreakdownlets ops see which arm produces results post-rollout — useful for validating the Phase 2.5 prereq-enrichment hypothesis. -
Error envelopes — handler returns friendly strings for the LLM to paraphrase (never throws into the LLM-tool-result):
- Malformed
toSlug/fromSlug—"That tutorial slug doesn't look right…" SparqlTimeoutError—"I couldn't find a learning path right now — the query timed out…"Telemetry tag:error: 'timeout'.SparqlSyntaxError/ generic —"Internal error finding a learning path…"Telemetry tag:error: 'syntax'orerror: 'unknown'.
- Malformed
-
AI-judge fixture — test/hybrid/joule-tool-pick-find-path.test.js — 12-prompt fixture asserting the LLM picks the right tool (findLearningPath vs getRelevantSteps vs checkCode vs no-tool) at ≥90% accuracy. Gated by
HYBRID_AI_TESTS=true. Regression guard against tool-descriptor changes.
Implementation: srv/lib/kg/joule-tool-find-path.js + srv/lib/kg/concepts-for-user.js (user-coverage helper). Architecture details: docs/developers/architecture/joule.md.
| URL | Method | Description | Auth |
|---|---|---|---|
/login |
GET | Triggers XSUAA login redirect | XSUAA |
/login/callback |
GET | OAuth2 callback (handled by AppRouter) | XSUAA |
/admin/rebuild |
POST | Hot-reload static content (tar.gz upload) | Bearer (REBUILD_API_KEY) |
The application defines its roles in xs-security.json. Endpoints in this document indicate which scope is required where applicable.
| Scope | Role Collection | Used by |
|---|---|---|
$XSAPPNAME.Admin |
"Tutorials Admin" | /admin-ui/, /analytics-ui/, /_dev, /admin/* OData, /admin/embeddings/stats |
$XSAPPNAME.SuperAdmin |
"Tutorials SuperAdmin" | promote_community_to_mission, publish_content (emergency), other SuperAdmin-gated admin actions |
$XSAPPNAME.MobileApp |
"Tutorials Scanner" | /scanner-ui/, /scanner-vue/ |
$XSAPPNAME.Tutorial.Author |
"Tutorials Author" | /tutorials-qa/*, /qa-search/*, /author/generateOsVariants, trigger_rebuild |
$XSAPPNAME.KnowledgeGraph.Admin |
"Tutorials Admin" | /graph/publishConcept, /graph/unpublishConcept, merge_concepts |
$XSAPPNAME.Tutorial.API |
"Tutorials API Consumer" | /graphql (authenticated GraphQL over DeveloperService) |
$XSAPPNAME.Tutorial.MCP |
"Tutorials MCP Users" | /mcp-auth/*, /mcp-admin/* (hosted MCP OAuth/JWT tiers); PAT minting at /admin-ui/#pats |
| (authenticated-user) | (any logged-in user) | /api/*, /display/*, /chat/*, /pats, /mcp-pat/* |
- AppRouter:
http://localhost:5000(runsapprouter/server.js) - CAP backend:
http://localhost:4004(runscds watch) - Hugo dev server:
http://localhost:1313(runsnpm run dev)
When running locally, the approuter proxies all API calls to CAP_BASE_URL (default http://localhost:4004) and injects mock Basic auth:
/admin/*→admin:admin/display/*→display:display- All others →
developer:developer
# 1. Health
curl http://localhost:4004/health
curl http://localhost:4004/health/db
# 2. Public data (build pipeline)
curl http://localhost:4004/build/catalog | jq '.missions | length'
curl http://localhost:4004/build/navigator | jq '.tutorials | length'
curl http://localhost:4004/build/co-completions | jq '. | length'
curl http://localhost:4004/build/repo-catalog | jq '. | length'
# 3. OData metadata
curl http://localhost:4004/api/\$metadata -H "Accept: application/xml"
curl http://localhost:4004/admin/\$metadata -H "Accept: application/xml"
curl http://localhost:4004/admin/analytics/\$metadata -H "Accept: application/xml"
curl http://localhost:4004/admin/exports/\$metadata -H "Accept: application/xml"
# 4. Content serving
curl http://localhost:4004/content/hashes | jq 'keys | length'
curl -I http://localhost:4004/content/tutorials/abap-dev-get-started
# 5. Search (unauthenticated)
curl "http://localhost:4004/search/Tutorials?\$search=abap" | jq '.value | length'
# 6. Auth (via approuter)
curl http://localhost:5000/auth/user # expect 401 or user JSON
# 7. UIs (via approuter — open in browser)
open http://localhost:5000/admin-ui/ # ToolPage shell (Admin scope)
open http://localhost:5000/analytics-ui/ # SQL + entity browser (Admin scope)
open http://localhost:5000/scanner-vue/ # Badge scanner (MobileApp scope)
open http://localhost:5000/me/ # Profile + Recent Activity timelineFocused pen-test suite added in #797. All framework defaults (CAP CSRF auto-enforcement, Fiori Elements token prefetch, sanitize-html, approuter CSP) cover the primary attack surface — these tests pin the coverage against regression.
| File | Type | Scope |
|---|---|---|
test/unit/srv/analytics-sql-validator.pen.test.js |
unit | Malicious SQL against AnalyticsService.runSelectQuery validator (DDL/DML, stacked queries, comment bypasses, disallowed tables, oversize input). Covers srv/lib/analytics-sql-validator.cjs. |
test/unit/scripts/sanitize-html.pen.test.js |
unit | OWASP XSS Filter Evasion Cheat Sheet payloads against tutorial-source sanitizer. Covers scripts/parsers/sanitize-html.ts. |
test/unit/scripts/check-hugo-safe-html.test.js |
unit | Unit coverage for the Hugo safeHTML grep guard (marker recognition, 3-line window, allow/deny cases). Covers scripts/check-hugo-safe-html.cjs. |
test/smoke/csrf-enforcement.test.js |
smoke | POST to /admin/Tags, /admin/Missions, /api/completeStep without a valid x-csrf-token → 4xx. Covers the CAP OData mutation surface. |
test/smoke/express-route-mutations.test.js |
smoke | Sweeps all 10 bearer-token-protected app.post(...) routes in srv/server.js (/content/publish, /content/rollback, /content/code-check-specs, /content/validate-answer-specs, and the 6 build endpoints), plus XSUAA-scoped and public-POST hardening. |
test/smoke/xss-reflection.test.js |
smoke | Read-only reflection check: probes /homepage/ and /tutorials/tutorial-platform-feature-cookbook for unescaped payloads, /search/?q=<payload> for reflected query strings, and /tutorials/<payload>/ for reflection on the 404 path. |
test/smoke/security-headers.test.js |
smoke | CSP, X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy on approuter HTML responses. |
scripts/check-hugo-safe-html.cjs |
build guard | Fails the build if any new safeHTML / safeHTMLAttr / printf "<%s>" lands in hugo/layouts/**/*.{html,xml,xsl} without a <!-- security-reviewed: ... --> marker within 3 lines above. Runs as npm run check:security-annotations (wired into prebuild). |
Running locally:
# All unit pen tests (fast, no external deps)
npx vitest run test/unit/srv/analytics-sql-validator.pen.test.js \
test/unit/scripts/sanitize-html.pen.test.js \
test/unit/scripts/check-hugo-safe-html.test.js \
--project unit
# All smoke pen tests (needs deployed DEV; SRV and/or approuter URLs)
SMOKE_BASE_URL="https://tutorial-system-dev-tutorials-approuter.cfapps.eu10-005.hana.ondemand.com" \
SMOKE_SRV_URL="https://tutorial-system-dev-tutorials-srv.cfapps.eu10-005.hana.ondemand.com" \
npx vitest run test/smoke/csrf-enforcement.test.js \
test/smoke/express-route-mutations.test.js \
test/smoke/xss-reflection.test.js \
test/smoke/security-headers.test.js \
--project smoke
# Hugo safeHTML guard
npm run check:security-annotationsPolicy for new findings: if a pen test uncovers a real vulnerability (not a test-expectation mismatch), file a follow-up issue referencing #797, skip that individual case with a .skip and a // TODO(#XXX) comment, and address it in a dedicated PR. This suite is a regression net, not a fix-it-all PR.
{ "branchPoints": [ { "id": "...", "picked": "...", "reason": "...", "confidence": 0.0, "allBranches": [{ "key": "...", "label": "..." }] } ], "altGroups": [ { "id": "...", "groupKey": "...", "picked": "...", "reason": "...", "confidence": 0.0, "allBranches": [{ "key": "...", "label": "..." }] } ], "skipPoints": [ { "stepNumber": 4, "skip": true, "reason": "...", "skipLabel": "...", "skipReason": "..." } ], "note": "...", // empty-shape signal (see below) "error": "..." // validation failure (see below) }