Skip to content

CAP Backend ​

Source: extracted from project README, 2026-05-25.

The CAP Node.js service is the complete replacement for the Java IMS Spring Boot application.

Services ​

CDS @requires is the in-process gate; the AppRouter (approuter/xs-app.json) adds additional XSUAA scope checks per route in front of /api/*, /admin/*, etc. β€” see docs/developers/operations/testing-endpoints.md for the canonical route β†’ scope mapping.

ServicePath@requiresPurpose
DeveloperService/apianyTutorial progress, step completion, event progress, ChatConfig public projection, getRelevantSteps for non-RAG fallback
AdminService/adminAdminFull CRUD over admin entities, GDPR anonymization, Joule settings (singleton), audit-log + change-tracking surface
AnalyticsService/admin/analyticsAdminAd-hoc analytics β€” entity browser over @analytics.exposed views + runSelectQuery action (SELECT-only, allowlisted, LIMIT 5001)
ExportsService/admin/exportsAdminLong-running export jobs (CSV / Excel) for missions, accounts, completions
DisplayService/displayDisplayAppEvent leaderboard, burnup charts, track stats β€” feeds the rotating event-monitor dashboard via Socket.IO events on the /ws/display namespace
ConsolidationService/api/v1ConsolidationScopeAccount merge + legacy IMS endpoint compatibility for cutover-era integrations
ScannerService/scannerauthenticated-userUI5 barcode-scanner functions: getContestant(accountNumber), claimPrize(recordId). Approuter route additionally gates with scope MobileApp
SearchService/searchanySearchableItems HANA full-text projection β€” backs the global search bar, navigator, and Joule's searchTutorials tool
ChatService/chatauthenticated-userORD-symmetric shell with no entities; the streaming work happens at POST /chat/stream (Express, registered in bootstrap)
EventStreamServiceevent-streamanyWebSocket + REST event-stream feed for live tutorial-completion broadcasts

The QA channel runs a parallel tutorials-srv-qa app (separate HDI container, separate slug-bytes URL) with its own service set focused on author-preview workflows: ContentService (preview, publish-to-QA), SearchService (QA-scoped), and the POST /preview/render endpoint consumed by the VSCode extension. All QA services require XSUAA scope Tutorial.Author. See docs/developers/operations/qa-channel-bootstrap.md.

Built-in CAP Endpoints ​

CAP exposes additional routes that aren't defined in CDS but are useful (and dangerous) in production. The defaults in srv/server.js lock these down β€” see lines 47-71 for the gate.

RouteWhat it isProduction state
/CAP launchpad / welcome page (lists services + entities)Blocked in production by an early-middleware 404 unless EXPOSE_CAP_UI=true
/_dev/*Same launchpad surface for cds watch-style dev explorationApprouter route gates with scope Admin; CAP only mounts it when cds.env.server.index = true (set by EXPOSE_CAP_UI=true)
/$api-docs/*Swagger UI + OpenAPI 3 specs (one per service, with diagram)Blocked by the same middleware. Only enabled when EXPOSE_CAP_UI=true; approuter route additionally requires scope Admin
/<service-path>/$metadataOData v4 metadata document β€” exists for every CDS service automaticallyAlways available behind the service's normal auth (@requires + approuter scope) β€” relied on by Fiori Elements + the admin shell components
/<service-path>/<EntitySet>OData v4 collection endpoints generated from entity / view declarations in each service .cdsAlways available; auth follows the parent service

EXPOSE_CAP_UI is intentionally NOT set on tutorials-srv in production. To temporarily enable the launchpad for debugging, set the env var on the live app, restart, and unset it when done:

bash
cf set-env tutorials-srv EXPOSE_CAP_UI true && cf restart tutorials-srv
# … debug via /_dev under an Admin token …
cf unset-env tutorials-srv EXPOSE_CAP_UI && cf restart tutorials-srv

tutorials-srv-qa explicitly sets EXPOSE_CAP_UI: false in mta.yaml (see .deploy/mta.yaml) β€” the QA service must never expose the launchpad even by accident, since the QA HDI container holds in-flight author content.

Custom Endpoints (Express) ​

Registered on cds.on('bootstrap') in srv/server.js (a few in served once cds.middlewares is available). These run as raw Express routes β€” no OData parsing, no entity layer β€” and either bypass CAP's auth entirely (public probes, build-time data) or compose context + auth middleware manually.

A few are registered in bootstrap specifically to reserve the path before CAP's OData router mounts a service at the same prefix (/admin/*, /chat/*). Without that reservation OData would interpret the path as a resource and return Invalid resource path / 404.

EndpointMethodAuthPurpose
/healthGETNoneLiveness probe β€” always returns 200 + timestamp
/health/dbGETNoneReadiness probe β€” runs SELECT 1 FROM DUMMY against the bound HDI; returns 503 on connection error
/auth/userGETXSUAAReturns { authenticated, id, email, givenName, familyName } for the current IDP session β€” used by Joule and the admin shell to greet by first name
/api/qrcodeGETXSUAA (via approuter)Generates a per-tutorial QR PNG for the event-floor scanner UI
/api/recommendationsGETXSUAA (via approuter)Personalized "what's next" rail β€” blends embedding centroid + co-completion
/build/catalogGETNoneMission + group catalog for the static-site Hugo build
/build/navigatorGETNoneSide-nav tree (mission β†’ group β†’ tutorial) for the navigator Vue island
/build/slug-mappingGETNoneNumeric-legacyId β†’ slug map, used by migration + redirect tooling
/build/co-completionsGETNoneCo-completion graph used by /api/recommendations and admin analytics
/build/repo-catalogGETNoneRepoCatalog snapshot β€” which slugs live in which sap-tutorials/* repo
/build/repo-catalogPOSTBearer CONTENT_API_KEYAuthoritative writer; rebuild-content.yml posts after every full fetch
/content/navGETNoneManifest navigation metadata for the active content version
/content/hashesGETNone{ slug: sha256 } map of currently-served tutorials β€” read by publish-content.ts for delta computation
/content/tutorials/*slugGETNone (via approuter /tutorials/*)Decompresses + serves tutorial HTML from HANA BLOBs with ETag + bounded LRU cache (50 MB)
/content/publishPOSTBearer CONTENT_API_KEYAccepts { trigger, hugoVersion, files: { slug: base64gzip } } β€” creates a new manifest version and triggers async embedding pipeline via setImmediate
/content/rollbackPOSTBearer CONTENT_API_KEYReverts the active manifest to the previous version
/feedback/submitPOSTNone (rate-limited, via approuter)Bridge to DeveloperService.submitTutorialFeedback action β€” derives client IP from leftmost X-Forwarded-For and injects via AsyncLocalStorage. Requires SUBMISSION_SALT_SECRET or returns 503
/chat/streamPOSTXSUAA + authenticated-userJoule SSE streaming β€” reserved in bootstrap (before ChatService mounts at /chat); the late-bound dispatcher is replaced in served once cds.middlewares exists
/admin/embeddings/statsGETXSUAA + AdminRAG embedding coverage stats for the Joule admin tile β€” reserved before AdminService OData router mounts at /admin
/admin/analytics/*ALLXSUAA + AdminReservation for the AnalyticsService OData adapter β€” without this, the AdminService OData router at /admin intercepts first and 404s
/admin/exports/exportLegacyDataGETXSUAA + AdminStreaming legacy-data export bridge β€” same OData-collision reservation pattern as the analytics path

/search is a regular CDS service surface but is wrapped in bootstrap with a per-IP rate limiter (srv/lib/ip-rate-limit.js; 60 req/min default, tunable via SEARCH_RATE_LIMIT_MAX / SEARCH_RATE_LIMIT_WINDOW_MS).

For the canonical end-to-end smoke matrix (route β†’ upstream service β†’ expected response), see docs/developers/operations/testing-endpoints.md.

WebSocket (Socket.IO) ​

Real-time event-floor dashboards subscribe to tutorial-completion events over a Socket.IO transport. Two CAP services expose the WS surface alongside their OData projections β€” see @protocol: ['odata', 'websocket'] on DisplayService (XSUAA DisplayApp, includes user name) and @protocol: ['websocket', 'rest'] on EventStreamService (anonymous, kiosk-friendly, payload minus PII). Clients connect to Socket.IO namespaces /ws/display and /ws/event-stream; the underlying transport URL is /socket.io/?EIO=4&transport=websocket.

Why Socket.IO, not raw WebSocket.

  • Topic-based fan-out is built in. Each event monitor subscribes only to its own event ID β€” the server filters automatically via the contexts: [String(event.legacyId)] argument to cds.connect.to('DisplayService').emit(...) (see srv/developer-service.js:494). The CAP WebSocket plugin maps contexts onto Socket.IO rooms; with raw WS we'd reinvent the routing table by hand.
  • Reconnection, heartbeats, and transport fallback come for free. socket.io-client (~30 KB minified) handles automatic reconnect, heartbeat ping/pong, and falls back from WebSocket to long-polling on locked-down networks β€” useful for kiosks behind corporate proxies.
  • Authenticated vs anonymous split is one config change, not a separate stack. DisplayService is gated by XSUAA scope DisplayApp; EventStreamService is anonymous (@requires: 'any') and emits the same payload minus PII. The handler in developer-service.js emits to both sequentially with the same contexts: filter.

Implementation. @cap-js-community/websocket is the CAP plugin doing the WS plumbing β€” declaring @protocol: 'websocket' on a service is enough to mount the namespace; emitting a CDS event (e.g. event tutorialCompleted { ... } in the .cds) becomes a Socket.IO event on the corresponding namespace. The transport is selected via "websocket": { "kind": "socket.io" } in package.json (the plugin also supports ws and STOMP). No custom broker code lives in this repo β€” socket.io@^4.8.0 runs in tutorials-srv, and socket.io-client@^4.8.0 ships in app/display-app/ and the event-display / app-space islands in hugo-apps/.

Production access. Approuter routes ^/socket\.io/ and ^/ws/ are wired with authenticationType: 'none' (see approuter/xs-app.json) β€” the WS handshake itself bypasses approuter auth, and DisplayService enforces the DisplayApp scope at the CAP layer when a connection joins the /ws/display namespace.

Scheduled Jobs ​

Registered via cds.on('served') in srv/jobs/scheduler.js. Every job is wrapped in runWithLock(name, durationMs, fn) (srv/jobs/job-lock.js) β€” only one instance runs each tick across the CF app fleet. The pipeline log row created per run is queryable in the admin shell with a virtual cfLogsUrl jumping straight to the matching Cloud Logging window (Β±10s/+30s padding around the run).

CronJobDescription
0 0 * * * (00:00 daily)cleanup-step-failuresRemoves StepFailure rows older than 90 days
0 1 * * * (01:00 daily)account-merge-batchProcesses scheduled account merges queued by ConsolidationService
0 */2 * * * (every 2h)ngds-retryRetries failed NGDS message deliveries
0 3 * * * (03:00 daily)content-gcPrunes ContentFiles versions in SUPERSEDED / ROLLED_BACK state older than 7 days, keeping the last 3 for rollback. Never touches ACTIVE or PUBLISHING
15 3 * * * (03:15 daily)pipeline-log-gcPrunes PipelineLog entries older than 30 days
30 3 * * * (03:30 daily)embedding-orphan-pruneDeletes TutorialEmbedding rows for slugs no longer in the active manifest
30 * * * * (hourly :30)content-publishing-sweepMarks PUBLISHING manifests stuck > 60 min as FAILED (recovers crashed publishes)
17 * * * * (hourly :17)embedding-reconciliationRe-embeds tutorial steps whose contentHash drifted; offset to :17 to dodge the :00 thundering herd
0 2 * * 0 (Sun 02:00 weekly)tutorial-metadata-reviewSelf-healing backfill of any missing TutorialMeta rows (publish writes them inline; this catches drift)
0 9 * * 1 (Mon 09:00 weekly)contributor-notificationsComputes stale-tutorial notifications, resolves recipients (author + admin CC), sends escalating emails (180-day threshold)
0 */4 * * * (every 4h)email-retryRetries failed mail deliveries from the MailQueue
0 0 2 1,7 * (Jan 2 / Jul 2 00:00)tag-cleanupRemoves unused Tags rows

Key Libraries (srv/lib/) ​

FilePurpose
accomplishment-evaluator.jsEvaluate badge/accomplishment rules against user progress
account-merge.jsMerge duplicate user accounts
admin-analytics-runner.jsExecute curated analytics queries against the allowlisted schema with PII guards
admin-analytics-schema.jsAllowlist of facts, dimensions, and PII denylist exposed via AnalyticsService
admin-docs-index.jsLoad + search the prebuilt admin-docs index used by the Joule searchAdminDocs tool
adobe-analytics.jsAdobe Analytics XML beacon on tutorial completion
analytics-sql-validator.cjsParse + restrict ad-hoc SQL to SELECT-only against allowlisted tables (runSelectQuery)
anonymization.jsBuild the operations descriptor to anonymize a user's personal data
build-catalog.jsBuild pipeline data (missions, paths, tutorials)
cf-logs-link.jsCompose Cloud Logging dashboard URLs from the CF binding + app metadata
chat-context.jsJoule chat personas (learner + admin) and grounding rules for tool use
chat-orchestrator.jsJoule turn loop: orchestration client, tool definitions, multi-turn dispatch
chat-rate-limit.jsPer-user 24-hour sliding-window chat rate limiter
co-completion.jsCached co-completion matrix ("learners who completed X also completed Y")
content-store.jsTutorial HTML BLOB store: publish, manifest versioning, decompress + serve
contributor-notifications.jsCompute stale tutorials, escalation routing, config helpers
embedding-client.jsAzure OpenAI embedding client with batching + retry
embedding-pipeline.jsCompute + persist tutorial step embeddings (chunked, distributed-lock guarded)
embedding-query.jsEmbed user query and rank steps by cosine similarity (RAG retrieval)
embedding-stats.jsAggregate embedding coverage stats for /admin/embeddings/stats
event-statistics.jsCompute task-completion totals + unique users for an event
export-helpers.jsCSV formatting helpers (TaskRecords export, time-spent rendering)
feedback-salt.jsDaily-rotating HMAC salt for hashing feedback submitter IPs
ip-rate-limit.jsPer-IP fixed-window rate limiter for unauthenticated routes
legacy-id.jsHANA sequence-backed legacy integer IDs
mail-client.jsNodemailer transport with BTP Mail binding, template rendering, retry
navigator-catalog.jsCached /build/navigator handler reading the NavigatorCatalog entity
ngds-client.jsNGDS analytics integration with dead-letter retry
pipeline-log.jsInsert + complete PipelineLog rows for content + embedding pipeline runs
qrcode-handler.jsQR code PNG generation
recommend.jsPersonalized "what's next" ranking blending embedding centroid + co-completion
repo-catalog.jsServe /build/repo-catalog (RepoCatalog rows decoded into a slug β†’ payload map)
slug-mapping.jsBuild the legacyId β†’ slug map for tutorials, missions, and completion paths
status-calculator.jsTutorial + mission progress and status calculation from completion counts
step-text-extractor.jsDecompress tutorial HTML and extract per-step text (handles parser v1 + v2)
tech-user-auth.jsBasic-auth tech-user store loaded from TECH_USERS env, timing-safe compare
ttl-cache.jsTiny in-memory TTL cache supporting sync values and Promise resolution
tutorial-centroid.jsCached per-tutorial embedding centroid (averaged step vectors)
tutorial-meta-init.jsBackfill TutorialMeta rows for tutorials missing one (catches drift)
user-progress.jsResolve XSUAA sub β†’ Users.ID and supply progress data for chat tools

Data Model (db/) β€” prod HDI container tutorials-hana ​

Namespace: com.sap.developers.ims. Files split by purpose:

FileRole
db/schema.cdsEntities, aspects (TaskBase, LegacyKeyed), enums (MissionType, TaskType, TaskStatus, ExperienceLevel)
db/schema-ext.cdsExtensions (Missions.groupOrder, TaskBase.primaryTagRef) + @analytics.exposed allowlist + @Aggregation.ApplySupported for the Analytics Explorer
db/views.cdsRead-only projections: Tasks, NavigatorCatalog, SearchableItems, CompletionAnalytics, ActiveLearnersDaily, TutorialFeedbackAggregate
db/persistence.cdsHANA storage hints (@cds.persistence.exists, table-type overrides)
db/audit-logging.cds@PersonalData annotations on Users / UserMetaData / TaskRecords for @cap-js/audit-logging
db/change-tracking.cds@changelog on ChatSettings (admin-mutable settings tracked via @cap-js/change-tracking)

Core entities by concern ​

Identity & people ​

  • Users β€” SAP IDP users (uuid, sapId, legacyId, email, names, avatarUrl)
  • UserMetaData β€” per-user preferences (theme, notification opt-ins)
  • PrimaryAccounts / SecondaryAccounts / PrivacyProtectionActions β€” account merge + GDPR anonymization audit

Learning content ​

  • Tutorials β€” Tutorial metadata (slug, title, time, level, steps)
  • Missions β€” Curated mission (slug, type: SEQUENTIAL/SET, groupOrder)
  • Groups β€” Group of tutorials inside a mission
  • Steps / Checkpoints β€” Step-level records used by progress tracking
  • CompletionPaths / CompletionPathItems / GroupPathItems β€” Ordered tutorialβ†’groupβ†’mission graph

Progress & rewards ​

  • TaskRecords β€” User completion records (step, tutorial, group, mission, checkpoint)
  • AccomplishmentRecords / Accomplishments β€” Earned badges and the catalog they reference
  • Events β€” Time-boxed learning events (TechEd, Sapphire, Joule launches)
  • Prizes / PrizeRecords / FeaturedTasks β€” Event prize pool, claimed records, hero-card promotions

Tagging ​

  • Tags / TutorialTags / GroupTags / MissionTags β€” Many-to-many tag assignments (primaryTagRef provides value-help association)

Tutorial sourcing & freshness ​

  • TutorialContributors / TutorialRepositories β€” GitHub authors and source repos
  • TutorialMeta β€” Notification tracking (reviewed date, escalation level)
  • RepoCatalog β€” Discovered-tutorial baseline (third-tier discovery fallback, written by CI)

Content persistence ​

  • ContentFiles β€” Versioned, gzip-compressed Hugo HTML BLOBs (slug + version PK)
  • ContentManifest β€” Publish manifest with status (PUBLISHING / ACTIVE / SUPERSEDED / ROLLED_BACK)
  • TutorialBodyText β€” Plain-text projection of active HTML, refreshed on every publish (powers full-text search)

Embeddings & chat ​

  • TutorialEmbedding β€” Per-step embedding vectors (HANA-only at query time; see Gotchas)
  • ChatSettings β€” Joule chat config (model, RAG flag, system prompt) β€” change-tracked

Feedback ​

  • TutorialFeedback β€” Per-tutorial NPS rating + comment (also rolled up by TutorialFeedbackAggregate view)

Operational & observability ​

  • ImsConfig β€” Key-value configuration store (notification gates, email lists)
  • JobLocks β€” Distributed lock rows for cron jobs
  • FailedEmails / StepFailures / NGDSFailedMessages β€” Persistent failure queues
  • ActiveLearnerRecords / DashboardMonitoredRecords β€” Live-event dashboard inputs
  • PipelineLog / PipelineLogItems / JobLogItems β€” Structured job-execution log (surfaced via cfLogsUrl virtual)
  • DeveloperEnvironmentTabs / DeveloperEnvironmentLinks β€” IDE quick-link metadata
  • TimeZones β€” Reference table for event scheduling

Data Model (db-qa/) β€” QA HDI container tutorials-hana-qa ​

The QA channel is a separate HDI container scoped to the Tutorial.Author XSUAA scope. It deploys from db-qa/schema.cds under a distinct namespace com.sap.developers.ims.qa and is consumed by the tutorials-srv-qa MTA module β€” no foreign keys, queries, or replication touch the prod tables in db/.

The QA model is intentionally narrow β€” it persists content + pipeline observability only. There are no user, progress, event, prize, tag, embedding, or feedback tables, because authors preview rendered HTML, they do not generate progress.

EntityRole
ContentFilesVersioned gzip-compressed Hugo HTML BLOBs (same shape as prod, isolated rows)
ContentManifestQA publish manifest (PUBLISHING / ACTIVE / SUPERSEDED / ROLLED_BACK)
TutorialBodyTextPlain-text projection for QA full-text search
TutorialsMinimal tutorial metadata for the QA navigator (slug, title, level, time)
RepoCatalogAuthor-preview discovery baseline (sourced only from *-Contribution repos)
JobLocksPer-container distributed locks for QA pipeline jobs
PipelineLog / PipelineLogItems / JobLogItemsQA pipeline execution log

QA-channel guardrails:

  • Fetch is gated by ONLY_CONTRIBUTION_REPOS=true so author drafts in *-Contribution repos never bleed into prod
  • Publish requires CONTENT_API_KEY_QA (separate from prod CONTENT_API_KEY)
  • Prod and QA schemas are kept aligned by the .github/workflows/schema-drift-check.yml workflow β€” any divergence in shared entity shapes fails CI

Notification Escalation System ​

Tutorial contributors receive escalating email reminders when tutorials go 6+ months without review:

LevelTOCCMessage
0 (First)Tutorial owner/authorβ€”90-day retirement warning
1 (Second)Tutorial owner/authorRepo owner60-day warning
2 (Third)Tutorial owner/authorRepo owner + admin list30-day warning
3 (Final)Admin listβ€”Deadline passed, arrange removal

Resend interval: 30 days between escalation levels. Controlled via ImsConfig entries isNotificationSendingAllowed and emailListForOutdated.