diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 6232075db..1463ec2ad 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.20.0 +version: 1.20.1 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: @@ -117,14 +117,15 @@ modules: KNOWLEDGE_GRAPH_ENABLED: 'false' KG_EXTRACT_BUILD_CAP: '200' KG_MERGE_SIM_THRESHOLD: '0.92' - # Phase 2 MCP feature flags (Task 15 #1105). Default 'true'. Override via cf set-env. - MCP_AUTH_ENABLED: 'true' - MCP_PAT_MINT_ENABLED: 'true' - KG_STEP_SLICER_ENABLED: 'true' - # KG_PATH_V2 (issue #913): property-graph shortest-path engine backing - # the Joule findLearningPath tool + CDS pathBetween + GET /graph/path - # (#1253). Default OFF; DEV override 'true' in deploy/dev.mtaext. - KG_PATH_V2_ENABLED: 'false' + # NOTE: the on/off feature flags MCP_AUTH_ENABLED, MCP_PAT_MINT_ENABLED, + # MCP_PHASE3_ENABLED, MCP_RESOURCES_ENABLED, MCP_PROMPTS_ENABLED, + # MCP_ADMIN_TOOLS_ENABLED, METRICS_ENABLED, KG_PAGERANK_ENABLED, + # KG_PATH_V2_ENABLED, KG_RETIRE_ORPHANS_ENABLED, KG_STEP_SLICER_ENABLED, + # COMMUNITY_BLOGS_CLASSIFIER_ENABLED, HOMEPAGE_NEWS_RELEVANCE_ENABLED and + # FRESHNESS_SCAN_ENABLED used to live here. They moved to DB-driven config + # (ImsConfig flag.* rows, read via srv/lib/feature-flags/db-flags.js) in + # issue #2060 — flip them from the Admin UI / setFeatureFlag action, no + # redeploy. Defaults are seeded on boot from the registry. # NOTE: CONTENT_API_KEY, GITHUB_DISPATCH_TOKEN, SMTP_HOST/PORT/USER/FROM # and REBUILD_TARGET_ENV used to live here. All resolved from the BTP # Credential Store now (via srv/lib/secret-resolver.js) or TenantSettings @@ -171,7 +172,7 @@ modules: - cp -r ../../hugo/assets ./hugo/assets - cp -r ../../hugo/data ./hugo/data - cp -r ../../hugo/i18n ./hugo/i18n - - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/island-manifest.json srv/lib/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" + - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js ../../srv/lib/runtime-config/alert-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-delta-flags.js ../../srv/lib/content-cache-coherence.js ../../srv/lib/edge-cache-headers.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js ../../srv/lib/page-key-map.js ../../srv/lib/page-fallback.js ../../srv/lib/task-record-submission-id.js ../../srv/lib/image-store.cjs ../../srv/lib/image-ingest.cjs ../../srv/lib/image-source-handler.js ../../srv/lib/img-cdn-fetch.cjs ../../srv/lib/img-cdn-retry.cjs ../../srv/lib/image-warm-utils.js ../../srv/lib/attachment-store.cjs ../../srv/lib/attachment-ingest.cjs ../../srv/lib/attachment-mime.cjs ../../srv/lib/attachment-warm-utils.js ../../srv/lib/attachment-source-handler.js ../../srv/lib/attachment-ingest-handler.js ../../srv/lib/island-manifest.json srv/lib/ && mkdir -p srv/lib/feature-flags && cp ../../srv/lib/feature-flags/db-flags.js ../../srv/lib/feature-flags/registry.js srv/lib/feature-flags/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" - bash -c "node -e \"const p=require('./package.json'); p.dependencies=Object.assign(p.dependencies||{},{cheerio:'^1.2.0','@sap-ai-sdk/foundation-models':'^2.10.0'}); require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2));\"" properties: EXPOSE_CAP_UI: false diff --git a/.github/workflows/rebuild-content-qa.yml b/.github/workflows/rebuild-content-qa.yml index 5f987aee9..e177a8381 100644 --- a/.github/workflows/rebuild-content-qa.yml +++ b/.github/workflows/rebuild-content-qa.yml @@ -19,6 +19,11 @@ on: required: false type: boolean default: true + force-publish: + description: 'Seed/repair Option B ContentCurrent (QA): publish ALL slugs (dual-write everything), bypassing the changed-slug diff. Use for a one-time ContentCurrent re-seed after the delta flags were toggled. Default false. Mirrors rebuild-content.yml force-publish.' + required: false + type: boolean + default: false env: NODE_VERSION: '22' @@ -259,10 +264,17 @@ jobs: run: npm run build:highlight -- --dir hugo/public-qa - name: Publish QA content to HANA - run: npx tsx scripts/publish-content.ts --channel qa --initiator "ci/${{ github.run_id }}" + run: npx tsx scripts/publish-content.ts --channel qa ${{ inputs.force-publish && '--force' || '' }} --initiator "ci/${{ github.run_id }}" env: CAP_QA_BASE_URL: ${{ secrets.CAP_SRV_URL_QA }} CONTENT_API_KEY_QA: ${{ secrets.CONTENT_API_KEY_QA }} + # Scope the publish to the changed slug on a commit-triggered (or single-slug + # workflow_dispatch) rebuild — publish-content.ts reads PUBLISH_SLUG as the + # --slug fallback. Empty on a full rebuild → whole-catalog delta. Mirrors + # rebuild-content.yml. Without force-publish, this makes the commit O(changed) + # (sub-second) instead of a ~35s full-catalog write. force-publish=true ignores + # the slug and re-seeds the whole catalog (--force). + PUBLISH_SLUG: ${{ (! inputs.force-publish) && (github.event.client_payload.slug || inputs.slug) || '' }} # #1373 — synthetic watchdog. The publish step reporting success is not # proof rows landed: the render-concepts 404 that hid for days (#1372) diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 7c87c2af0..7d56b845a 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -4130,7 +4130,15 @@ annotate AdminService.FeatureFlags with @UI: { { Value: effectiveValue, Label: 'Effective' }, { Value: winningLayer, Label: 'Source' }, { Value: status }, - { Value: issue } + { Value: issue }, + // #2060 — row-button toggles for kind:'db' flags (non-db flags reject 400). + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.FeatureFlags/enable', Label: 'Enable' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.FeatureFlags/disable', Label: 'Disable' } + ], + // Object Page header actions — same enable/disable on the detail view (#2060). + Identification: [ + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.FeatureFlags/enable', Label: 'Enable' }, + { $Type: 'UI.DataFieldForAction', Action: 'AdminService.FeatureFlags/disable', Label: 'Disable' } ], Facets: [ { $Type: 'UI.ReferenceFacet', ID: 'General', Label: 'General', Target: '@UI.FieldGroup#General' }, diff --git a/app/admin/featureFlags/webapp/i18n/i18n.properties b/app/admin/featureFlags/webapp/i18n/i18n.properties index 8362d4b4d..8c49cd7c9 100644 --- a/app/admin/featureFlags/webapp/i18n/i18n.properties +++ b/app/admin/featureFlags/webapp/i18n/i18n.properties @@ -1,2 +1,7 @@ appTitle=Feature Flags appSubtitle=Live state of runtime feature flags + +# Bound row-action labels (#2060) — annotations use inline labels; these mirror +# them for any i18n-driven references and for translation coverage. +enableFlag=Enable +disableFlag=Disable diff --git a/approuter/lib/mcp-auth-challenge.js b/approuter/lib/mcp-auth-challenge.js new file mode 100644 index 000000000..d35a637aa --- /dev/null +++ b/approuter/lib/mcp-auth-challenge.js @@ -0,0 +1,35 @@ +// approuter/lib/mcp-auth-challenge.js +// +// Emits the MCP-spec-preferred discovery trigger: a 401 with a +// `WWW-Authenticate: Bearer resource_metadata="…"` pointer on the protected MCP +// namespaces, so compliant clients follow the pointer to the protected-resource +// metadata instead of blindly probing /.well-known. Runtime middleware; mirrors +// the srv-side /mcp-pat short-circuit in srv/server.js. Only fires when NO +// Authorization header is present, so a valid bearer always passes through. + +const { resolveBaseUrl, resolveScope } = require('./well-known-oauth') + +const MCP_AUTH_PREFIXES = ['/mcp-auth', '/mcp-admin'] + +function matchesProtectedMcp(pathOnly) { + return MCP_AUTH_PREFIXES.some(p => pathOnly === p || pathOnly.startsWith(p + '/')) +} + +function mcpAuthChallengeHandler(req, res, next) { + const pathOnly = (req.url || '').split('?')[0] + if (!matchesProtectedMcp(pathOnly)) return next() + + const authz = req.headers && req.headers.authorization + if (authz && authz.startsWith('Bearer ')) return next() + + const baseUrl = resolveBaseUrl(req) + const scope = resolveScope() + if (baseUrl) { + res.setHeader('WWW-Authenticate', + `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="${scope}"`) + } + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'unauthorized', error_description: 'Bearer token required.' })) +} + +module.exports = { mcpAuthChallengeHandler, MCP_AUTH_PREFIXES } diff --git a/approuter/lib/well-known-mcp-manifest.js b/approuter/lib/well-known-mcp-manifest.js new file mode 100644 index 000000000..31f94ee0b --- /dev/null +++ b/approuter/lib/well-known-mcp-manifest.js @@ -0,0 +1,43 @@ +// approuter/lib/well-known-mcp-manifest.js +// +// Serves /.well-known/mcp.json — a NON-STANDARD convenience manifest listing the +// hosted MCP endpoints. NOT part of the MCP spec (server publishing is via the +// central MCP Registry's server.json); we serve it as a courtesy. Runtime +// middleware, mirroring well-known-oauth.js / security-txt.js. Base URL from the +// request; scope from the bound XSUAA binding (qualified form). + +const { resolveBaseUrl, resolveScope } = require('./well-known-oauth') + +const MCP_MANIFEST_PATH = '/.well-known/mcp.json' + +function buildManifest(baseUrl, scope) { + return { + $comment: 'Non-standard convenience manifest; not part of the MCP specification.', + name: 'SAP Developers MCP', + provider: 'SAP Tutorials (developers.sap.com)', + servers: [ + { name: 'search', url: `${baseUrl}/mcp/search`, auth: 'none' }, + { name: 'homepage', url: `${baseUrl}/mcp/homepage`, auth: 'none' }, + { name: 'graph', url: `${baseUrl}/mcp/graph`, auth: 'none' }, + { name: 'developer', url: `${baseUrl}/mcp-auth/api`, auth: 'oauth2', scope }, + ], + authorization: { protected_resource: `${baseUrl}/.well-known/oauth-protected-resource` }, + } +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' }) + res.end(JSON.stringify(body, null, 2)) +} + +function mcpManifestHandler(req, res, next) { + if (req.method !== 'GET' && req.method !== 'HEAD') return next() + const pathOnly = (req.url || '').split('?')[0] + if (pathOnly !== MCP_MANIFEST_PATH) return next() + + const baseUrl = resolveBaseUrl(req) + if (!baseUrl) return sendJson(res, 503, { error: 'mcp_manifest_unavailable' }) + return sendJson(res, 200, buildManifest(baseUrl, resolveScope())) +} + +module.exports = { mcpManifestHandler, MCP_MANIFEST_PATH, buildManifest } diff --git a/approuter/lib/well-known-oauth.js b/approuter/lib/well-known-oauth.js index d8fb54999..0e0ce2449 100644 --- a/approuter/lib/well-known-oauth.js +++ b/approuter/lib/well-known-oauth.js @@ -23,6 +23,7 @@ const AUTH_SERVER_PATH = '/.well-known/oauth-authorization-server' const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource' +const OPENID_CONFIG_PATH = '/.well-known/openid-configuration' // The MCP authenticated mount the resource metadata advertises. const MCP_RESOURCE_SUFFIX = '/mcp-auth' @@ -117,7 +118,7 @@ function wellKnownOAuthHandler(req, res, next) { // Strip any query string before matching. const pathOnly = (req.url || '').split('?')[0] - if (pathOnly !== AUTH_SERVER_PATH && pathOnly !== PROTECTED_RESOURCE_PATH) { + if (pathOnly !== AUTH_SERVER_PATH && pathOnly !== PROTECTED_RESOURCE_PATH && pathOnly !== OPENID_CONFIG_PATH) { return next() } @@ -130,7 +131,7 @@ function wellKnownOAuthHandler(req, res, next) { const scope = resolveScope() - if (pathOnly === AUTH_SERVER_PATH) { + if (pathOnly === AUTH_SERVER_PATH || pathOnly === OPENID_CONFIG_PATH) { return sendJson(res, 200, authorizationServerMetadata(issuer, scope)) } @@ -149,4 +150,5 @@ module.exports = { protectedResourceMetadata, AUTH_SERVER_PATH, PROTECTED_RESOURCE_PATH, + OPENID_CONFIG_PATH, } diff --git a/approuter/server.js b/approuter/server.js index 95c89e5e3..53dddde65 100644 --- a/approuter/server.js +++ b/approuter/server.js @@ -21,6 +21,8 @@ const { normalizeLegacyCatalogUrl } = require('./lib/catalog-legacy-redirects') const { bump, startAutoFlush } = require('./lib/hit-counter') const { safeFetch } = require('./lib/safe-fetch') const { wellKnownOAuthHandler } = require('./lib/well-known-oauth') +const { mcpManifestHandler } = require('./lib/well-known-mcp-manifest') +const { mcpAuthChallengeHandler } = require('./lib/mcp-auth-challenge') const { securityTxtHandler } = require('./lib/security-txt') const { sitemapIndexRedirectHandler } = require('./lib/sitemap-index-redirect') const shouldProcessImage = require('./lib/img-cdn-should-process') @@ -592,8 +594,10 @@ ar.start({ insertMiddleware: { first: [ { path: '/', handler: wellKnownOAuthHandler }, + { path: '/', handler: mcpManifestHandler }, { path: '/', handler: securityTxtHandler }, { path: '/', handler: sitemapIndexRedirectHandler }, + { path: '/', handler: mcpAuthChallengeHandler }, { path: '/', handler: devtoberfestCspHandler }, { path: '/', handler: imgCdnHandler }, { path: '/', handler: legacyRedirectsHandler }, diff --git a/db/data/staging/homepage-thirdparty.json b/db/data/staging/homepage-thirdparty.json deleted file mode 100644 index 4dbf7e77c..000000000 --- a/db/data/staging/homepage-thirdparty.json +++ /dev/null @@ -1,281 +0,0 @@ -[ - { - "_comment": "HOW TO PROMOTE TO PROD: after review on DEV, either (a) append these rows to db/data/com.sap.developers.ims-HomepageShelves.csv and redeploy PROD, or (b) run `npm run seed:thirdparty` against the PROD cds profile. This file stays the source of truth. Rows loaded to DEV via `npm run seed:thirdparty`. See docs/superpowers/specs/2026-07-30-homepage-thirdparty-content-design.md" - }, - { - "ID": "66333900-rpt1-0005-0001-000000000001", - "verb": "AI", "shelf": "START_HERE", "sortOrder": 26, - "title": "SAP RPT-1 Playground", - "url": "https://rpt.cloud.sap", - "description": "Try SAP's RPT-1 tabular foundation model in the browser — bring your own data or use SAP-provided example datasets.", - "badge": "NEW", "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Predict on your tables with RPT-1 — no training, right in the browser.", - "whyItMatters": "RPT-1 is SAP's semantics-aware foundation model for tabular data. The playground lets you run classification and regression on your own tables or SAP sample datasets without any setup — the fastest way to see what the model does.", - "personaTags": ["role:developer", "role:student", "deployment:cloud"] - }, - { - "ID": "66333900-rpt1-0005-0001-000000000002", - "verb": "AI", "shelf": "REFERENCE", "sortOrder": 35, - "title": "SAP RPT-1 on Hugging Face", - "url": "https://huggingface.co/SAP/sap-rpt-1-oss", - "description": "Open-source release of SAP RPT-1 — model card, weights, and usage examples for the tabular foundation model.", - "badge": "NEW", "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "The open-source RPT-1 model card, weights, and examples.", - "whyItMatters": "RPT-1 is published open-source on Hugging Face as a semantics-aware tabular in-context learner for classification and regression. The model card is the reference for developers integrating it into their own pipelines.", - "personaTags": ["role:developer", "role:student", "deployment:cloud"] - }, - { - "ID": "66333900-rpt1-1029-0001-000000000001", - "verb": "MODEL", "shelf": "REFERENCE", "sortOrder": 65, - "title": "SAP RPT-1", - "url": "https://rpt.cloud.sap", - "description": "SAP's relational/tabular foundation model — predict directly on structured data without task-specific training.", - "badge": "NEW", "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "A foundation model that predicts directly on your data models.", - "whyItMatters": "RPT-1 brings foundation-model prediction to structured, tabular data — the shape of most SAP business data. For teams modeling data in HANA Cloud or Datasphere, it is a fast path to classification and regression without building a bespoke ML pipeline.", - "personaTags": ["role:developer", "role:architect", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-0002-0001-000000000001", - "verb": "BUILD", "shelf": "TOOLS", "sortOrder": 200, - "title": "Vercel", - "url": "https://vercel.com", - "description": "Frontend cloud for building and deploying React, Vue, and static sites with global edge delivery.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Deploy React and Vue frontends to a global edge in minutes.", - "whyItMatters": "CAP treats React and Vue as first-class frontends. Vercel is a common host for those SPAs, giving developers preview deployments, edge functions, and CI-driven releases that pair well with a CAP backend on BTP.", - "personaTags": ["role:developer", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-1029-0001-000000000001", - "verb": "MODEL", "shelf": "REFERENCE", "sortOrder": 200, - "title": "Dremio", - "url": "https://www.dremio.com", - "description": "Lakehouse platform for SQL analytics directly on data lake storage, built around Apache Iceberg.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Query your data lake with SQL — no copies, no cubes.", - "whyItMatters": "Dremio is a leading open lakehouse engine. For teams federating SAP data with lake storage, it is a practical reference for Iceberg-based analytics alongside SAP Datasphere and Business Data Cloud.", - "personaTags": ["role:developer", "role:architect", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-1029-0001-000000000002", - "verb": "MODEL", "shelf": "KEEP_CURRENT", "sortOrder": 210, - "title": "Dremio Community", - "url": "https://community.dremio.com", - "description": "Community forum for Dremio users — Q&A, how-tos, and release discussion.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Ask questions and share patterns with other Dremio users.", - "whyItMatters": "The Dremio community forum is where lakehouse practitioners troubleshoot Iceberg, reflections, and federation — useful when integrating lake data with SAP analytics.", - "personaTags": ["role:developer", "role:architect"] - }, - { - "ID": "66333900-3rd0-1029-0001-000000000003", - "verb": "MODEL", "shelf": "REFERENCE", "sortOrder": 220, - "title": "Apache Iceberg", - "url": "https://iceberg.apache.org", - "description": "Open table format for huge analytic datasets — the storage standard behind modern lakehouses.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "The open table format underpinning modern lakehouses.", - "whyItMatters": "Iceberg is the table format SAP Business Data Cloud and many lake engines build on. Understanding it helps architects reason about how SAP and non-SAP data interoperate at the storage layer.", - "personaTags": ["role:developer", "role:architect", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-1029-0001-000000000004", - "verb": "MODEL", "shelf": "KEEP_CURRENT", "sortOrder": 230, - "title": "Data Engineering Weekly", - "url": "https://www.dataengineeringweekly.com", - "description": "Curated weekly newsletter on data engineering trends, tools, and architecture.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Stay current on the wider data-engineering ecosystem.", - "whyItMatters": "A concise weekly read that keeps data architects aware of trends beyond the SAP stack — pipelines, formats, and platform shifts that influence integration choices.", - "personaTags": ["role:architect"] - }, - { - "ID": "66333900-3rd0-1029-0001-000000000005", - "verb": "MODEL", "shelf": "KEEP_CURRENT", "sortOrder": 240, - "title": "The Data Stack Show", - "url": "https://datastackshow.com", - "description": "Podcast with data engineers and founders on how modern data stacks are built and run.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Conversations on how real data stacks get built.", - "whyItMatters": "Practitioner interviews that surface the tradeoffs behind modern data platforms — helpful context for teams positioning SAP data products within a broader stack.", - "personaTags": ["role:developer", "role:architect"] - }, - { - "ID": "66333900-3rd0-1029-0001-000000000006", - "verb": "MODEL", "shelf": "KEEP_CURRENT", "sortOrder": 250, - "title": "Reltio Community", - "url": "https://community.reltio.com", - "description": "Community for Reltio master-data-management practitioners — Q&A and best practices.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Master-data-management practices from the Reltio community.", - "whyItMatters": "MDM is a frequent companion to SAP data landscapes. The Reltio community is a reference point for entity resolution and data-quality patterns that complement SAP master data.", - "personaTags": ["role:developer", "role:architect"] - }, - { - "ID": "66333900-3rd0-0005-0001-000000000001", - "verb": "AI", "shelf": "REFERENCE", "sortOrder": 200, - "title": "Hugging Face", - "url": "https://huggingface.co", - "description": "Hub for open models, datasets, and ML tooling — the de facto registry for the AI ecosystem.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "The open hub for models, datasets, and ML tooling.", - "whyItMatters": "Hugging Face is where most open models and datasets live. Developers building AI on BTP often source or evaluate models here before deploying via SAP AI Core.", - "personaTags": ["role:developer", "role:student", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-0005-0001-000000000002", - "verb": "AI", "shelf": "TOOLS", "sortOrder": 210, - "title": "TabPFN (Prior Labs)", - "url": "https://github.com/PriorLabs/TabPFN", - "description": "Foundation model for tabular data that delivers strong results on small datasets without training.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "A foundation model for tabular data — no training required.", - "whyItMatters": "Most enterprise data is tabular. TabPFN is a notable open model for tabular prediction, relevant to developers exploring ML on structured SAP data.", - "personaTags": ["role:developer", "role:student"] - }, - { - "ID": "66333900-3rd0-0005-0001-000000000003", - "verb": "AI", "shelf": "REFERENCE", "sortOrder": 220, - "title": "Prior Labs Research", - "url": "https://priorlabs.ai/research", - "description": "Research and technical reports behind TabPFN and tabular foundation models.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "The research behind tabular foundation models.", - "whyItMatters": "Prior Labs publishes the papers and reports underpinning TabPFN, including work featured in Nature — a primary source for developers evaluating the approach.", - "personaTags": ["role:developer", "role:student"] - }, - { - "ID": "66333900-3rd0-0005-0001-000000000004", - "verb": "AI", "shelf": "TOOLS", "sortOrder": 230, - "title": "Kaggle", - "url": "https://www.kaggle.com", - "description": "Platform for datasets, notebooks, and ML competitions with a large practitioner community.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Datasets, notebooks, and competitions to sharpen ML skills.", - "whyItMatters": "Kaggle is a practical training ground for data science. Its datasets and notebooks help developers and students build the ML skills they later apply on SAP data.", - "personaTags": ["role:developer", "role:student"] - }, - { - "ID": "66333900-3rd0-0003-0001-000000000001", - "verb": "INTEGRATE", "shelf": "KEEP_CURRENT", "sortOrder": 200, - "title": "n8n Community Forum", - "url": "https://community.n8n.io", - "description": "Official forum for the n8n workflow-automation community — Q&A, templates, and help.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Get help and share n8n automation workflows.", - "whyItMatters": "n8n is a popular open workflow-automation tool used to integrate SAP and non-SAP systems. Its forum is the primary place to find node patterns and troubleshoot flows.", - "personaTags": ["role:developer", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-0003-0001-000000000002", - "verb": "INTEGRATE", "shelf": "KEEP_CURRENT", "sortOrder": 210, - "title": "n8n Discord", - "url": "https://discord.gg/n8n", - "description": "Real-time chat community for n8n users and contributors.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Real-time chat with the n8n community.", - "whyItMatters": "The n8n Discord is where users get quick answers and share in-progress automations — a fast channel when building integrations that touch SAP endpoints.", - "personaTags": ["role:developer"] - }, - { - "ID": "66333900-3rd0-0003-0001-000000000003", - "verb": "INTEGRATE", "shelf": "KEEP_CURRENT", "sortOrder": 220, - "title": "n8n on YouTube", - "url": "https://www.youtube.com/c/n8n-io", - "description": "Official n8n channel — tutorials, feature demos, and automation walkthroughs.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Video tutorials and demos for n8n automation.", - "whyItMatters": "n8n's YouTube channel walks through building automations step by step — a fast way to learn node patterns before wiring up SAP integrations.", - "personaTags": ["role:developer"] - }, - { - "ID": "66333900-3rd0-0003-0001-000000000004", - "verb": "INTEGRATE", "shelf": "TOOLS", "sortOrder": 230, - "title": "n8n (n8n-io/n8n)", - "url": "https://github.com/n8n-io/n8n", - "description": "Source repository for the n8n workflow-automation platform.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "The open-source n8n automation engine on GitHub.", - "whyItMatters": "The n8n repo is the source of truth for the automation engine and its nodes — the place to file issues, read code, and understand how integrations execute.", - "personaTags": ["role:developer", "deployment:cloud"] - }, - { - "ID": "66333900-3rd0-0003-0001-000000000005", - "verb": "INTEGRATE", "shelf": "TOOLS", "sortOrder": 240, - "title": "n8n docs (n8n-io/n8n-docs)", - "url": "https://github.com/n8n-io/n8n-docs", - "description": "Documentation source for n8n — node references and self-hosting guides.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Docs and node references for n8n, open to contributions.", - "whyItMatters": "The n8n-docs repo holds node references and self-hosting guides, and accepts public PRs — useful when documenting a custom SAP integration node.", - "personaTags": ["role:developer"] - }, - { - "ID": "66333900-3rd0-0006-0001-000000000001", - "verb": "CONNECT", "shelf": "KEEP_CURRENT", "sortOrder": 200, - "title": "r/SAP", - "url": "https://www.reddit.com/r/SAP", - "description": "Reddit community discussing SAP products, careers, and day-to-day practice.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Candid SAP discussion from the wider practitioner community.", - "whyItMatters": "r/SAP is an unfiltered view of what SAP practitioners are dealing with — a useful pulse-check beyond official channels.", - "personaTags": ["role:developer"] - }, - { - "ID": "66333900-3rd0-0006-0001-000000000002", - "verb": "CONNECT", "shelf": "KEEP_CURRENT", "sortOrder": 210, - "title": "r/dataengineering", - "url": "https://www.reddit.com/r/dataengineering", - "description": "Reddit community for data engineers — tooling debates, career, and architecture.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Where data engineers debate tools and architecture.", - "whyItMatters": "A high-signal community for data-platform trends and tradeoffs that inform how SAP data fits a broader engineering stack.", - "personaTags": ["role:architect"] - }, - { - "ID": "66333900-3rd0-0006-0001-000000000003", - "verb": "CONNECT", "shelf": "KEEP_CURRENT", "sortOrder": 220, - "title": "r/MachineLearning", - "url": "https://www.reddit.com/r/MachineLearning", - "description": "Reddit community covering ML research, tooling, and applied practice.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "ML research and applied practice, community-curated.", - "whyItMatters": "Keeps developers aware of ML advances they may bring to SAP data — from model families to applied techniques.", - "personaTags": ["role:developer"] - }, - { - "ID": "66333900-3rd0-0006-0001-000000000004", - "verb": "CONNECT", "shelf": "KEEP_CURRENT", "sortOrder": 230, - "title": "r/n8n", - "url": "https://www.reddit.com/r/n8n", - "description": "Reddit community for n8n workflow automation — recipes and troubleshooting.", - "badge": null, "isExternal": true, "isActive": true, - "authoringStatus": "REVIEWED", "personaWeight": 0, - "tagline": "Automation recipes and troubleshooting for n8n.", - "whyItMatters": "A community source for n8n automation recipes, complementing the official forum when building integrations across SAP and other systems.", - "personaTags": ["role:developer"] - } -] diff --git a/deploy/dev.mtaext b/deploy/dev.mtaext index 9b1933c06..7ef9df55b 100644 --- a/deploy/dev.mtaext +++ b/deploy/dev.mtaext @@ -28,12 +28,9 @@ modules: # MTA. prod.mtaext / qa.mtaext intentionally inherit 'false' until # Phase 1 is signed off. KNOWLEDGE_GRAPH_ENABLED: 'true' - # KG_PATH_V2 on for DEV (#1253): routes findLearningPath through the - # true-shortest-path engine. Also activates it for the CDS pathBetween - # action + GET /graph/path (both fail open to v1). Pinned here so MTA - # redeploys preserve the flip — cf set-env does NOT survive cf deploy. - # prod.mtaext / qa.mtaext intentionally inherit 'false'. - KG_PATH_V2_ENABLED: 'true' + # NOTE: KG_PATH_V2_ENABLED moved to DB-driven config (ImsConfig + # flag.kg.pathV2, issue #2060). Enable it for DEV from the Admin UI / + # setFeatureFlag action instead of pinning an env override here. - name: tutorials-approuter parameters: diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a90879dd8..37be7acc8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -163,6 +163,7 @@ export default defineConfig({ { text: 'Advocate export/import', link: '/developers/operations/advocate-export-import' }, { text: 'Agent isolation hooks', link: '/developers/operations/agent-isolation-hooks' }, { text: 'AI-author CI setup', link: '/developers/operations/ai-author-ci-setup' }, + { text: 'Akamai .well-known forward', link: '/developers/operations/akamai-well-known-forward' }, { text: 'Author access to Admin UI', link: '/developers/operations/author-admin-access' }, { text: 'Branching strategy', link: '/developers/operations/branching-strategy' }, { text: 'BTP destinations (SCI / NGDS)', link: '/developers/operations/btp-destinations' }, diff --git a/docs/developers/architecture/build.md b/docs/developers/architecture/build.md index e3ffb2bfb..97511db59 100644 --- a/docs/developers/architecture/build.md +++ b/docs/developers/architecture/build.md @@ -53,7 +53,7 @@ flowchart TB subgraph publish[Content publish] PublishProd["publish-content.ts
delta-aware, gzip,
sha256 hash compare"] - PublishQa["publish-content.ts
--channel qa
(always --force)"] + PublishQa["publish-content.ts
--channel qa
(delta; slug-scoped via PUBLISH_SLUG,
--force for full re-seed)"] end subgraph deployed[Deployed targets] @@ -184,7 +184,7 @@ build:qa → hugo --config ../hugo.qa.toml → hugo/public-qa/ (strips Joule FAB, rating, completion buttons, progress UI) ├─ verify-qa-build.ts fails the build if QA-only stripping didn't apply ↓ -publish-content:qa (always --force; CONTENT_API_KEY_QA) +publish-content:qa (delta by default; PUBLISH_SLUG scopes to the changed slug; --force = full re-seed; CONTENT_API_KEY_QA) ↓ tutorials-srv-qa /content/publish ↓ diff --git a/docs/developers/architecture/homepage.md b/docs/developers/architecture/homepage.md index b39c3d2c9..713305bea 100644 --- a/docs/developers/architecture/homepage.md +++ b/docs/developers/architecture/homepage.md @@ -148,8 +148,22 @@ Managed via the `LegacyRedirects` CDS entity. The approuter middleware (`approut **Nightly link-health:** The `homepage-link-health` cron job (04:00 daily) sends HEAD requests to every active `HomepageShelves.url` and writes `linkStatus` (`OK` | `SLOW` | `BROKEN`) + `lastChecked` back to the entity. Broken links surface as a red dot on the Shelves tab in the admin UI. Threshold for SLOW is 1500ms (default); timeout is 5000ms per URL; concurrency is 4 with 200ms between requests. +## HomepageShelves seeding (baseline is boot-seeded, not CSV) + +`HomepageShelves` is **admin-managed on HANA** — its production HDI artifacts are structure-only (`.hdbtable` + unique index + change-tracking triggers), with **no `.hdbtabledata`**, so a deploy can never full-replace the table. + +The canonical baseline (SAP-owned entries + curated third-party links such as Prior Labs / Dremio / Reltio / n8n) lives inline in **`srv/lib/homepage/homepage-shelves-defaults.js`** and is seeded idempotently at boot by **`srv/lib/homepage/seed-homepage-shelves.js`** (called from `cds.on('served')` in `srv/server.js`). The seed is **insert-if-missing on `(verb,url)`**: it adds only rows that aren't already present, so admin edits are never overwritten, deleted baseline rows self-heal on the next restart, and it runs safely (non-fatal) in every profile — `cds watch`, unit tests, hybrid, and production. + +This replaced two fragile predecessors (both retired): + +- the `test/data/com.sap.developers.ims-HomepageShelves.csv` seed CSV, which — while it lived in `db/data` — compiled to an `.hdbtabledata` with `include_filter: []` and **full-replaced the table on every deploy whose CSV hash changed**, wiping curated/third-party rows and admin edits (the root cause of the recurring "shelf links disappeared" incidents); and +- the manual `npm run seed:thirdparty` promotion step (DEV/PROD-ambiguous — it followed the CDS binding, so it could silently target the wrong environment). + +To add or curate a baseline link: edit `homepage-shelves-defaults.js`, or use `/admin-ui/#homepage` at runtime (see **Add shelf entry** above). No CSV, no manual seed script. + --- + ## Explainer popovers Issue #759 adds progressive-disclosure explainers to the homepage verb spine, the directory footer, and the verb sub-page link cards. The data model (`VerbDefinitions`, `ShelfDefinitions`, plus three new fields on `HomepageShelves`), build feeds, AI-generation actions, Vue islands, and admin workflow are documented separately to keep this file focused. diff --git a/docs/developers/architecture/mcp-server.md b/docs/developers/architecture/mcp-server.md index d87e31fdd..92dda523a 100644 --- a/docs/developers/architecture/mcp-server.md +++ b/docs/developers/architecture/mcp-server.md @@ -83,13 +83,30 @@ The slicer uses raw `db.run()` for BLOB retrieval to avoid the CAP/HANA LOB loca ## `.well-known` discovery -Two OAuth discovery documents are served at the approuter level, enabling MCP 2.1 OAuth auto-discovery (RFC 8414 + RFC 9728): - -- **`/.well-known/oauth-authorization-server`** — populated from the XSUAA/IAS instance's metadata. Fields: `issuer`, `authorization_endpoint`, `token_endpoint`, `code_challenge_methods_supported: ['S256']`, `token_endpoint_auth_methods_supported: ['none']` (public clients). Per-env values are substituted at deploy time from `deploy/dev.mtaext` / `deploy/prod.mtaext`. - -- **`/.well-known/oauth-protected-resource`** — identifies this server as a protected resource (RFC 9728). Fields: `resource`, `scopes_supported: ['Tutorial.MCP']`, `authorization_servers: []`. - -Both documents are served as static JSON by the approuter (`approuter/static/.well-known/`). No CAP backend round-trip. Their content-type is `application/json`. +Discovery documents are served as **runtime approuter middleware** +(`insertMiddleware.first` in `approuter/server.js`), NOT static files — values +derive at request time from the bound XSUAA VCAP credentials and the request +host, so they are correct in every environment with no build-time substitution. + +- `approuter/lib/well-known-oauth.js` — `/.well-known/oauth-authorization-server` + (RFC 8414), its alias `/.well-known/openid-configuration`, and + `/.well-known/oauth-protected-resource` (RFC 9728). The `openid-configuration` + alias returns the OAuth Authorization Server Metadata body (RFC 8414), + intentionally omitting OIDC-only fields like `jwks_uri`; MCP OAuth-fallback + clients consume only the OAuth endpoints. `authorization_servers`/`issuer` + point at the XSUAA URL (Option A); `scopes_supported` advertises the + fully-qualified `.Tutorial.MCP` (bare `Tutorial.MCP` is rejected by + XSUAA with `invalid_scope`). +- `approuter/lib/well-known-mcp-manifest.js` — `/.well-known/mcp.json`, a + non-standard courtesy manifest listing the MCP mounts. Not part of the MCP spec. +- `approuter/lib/security-txt.js` — `/.well-known/security.txt` (RFC 9116). +- `approuter/lib/mcp-auth-challenge.js` — a 401 on `/mcp-auth/*` and `/mcp-admin/*` + without a bearer carries `WWW-Authenticate: Bearer resource_metadata="…"`, the + MCP-preferred discovery trigger. + +**Edge note:** on `developers.sap.com`, Akamai 403s `/.well-known/*` at the edge +except `security.txt`. The edge must forward these paths to origin — see +[operations/akamai-well-known-forward.md](../operations/akamai-well-known-forward.md). ## Phase 3 — the compose layer diff --git a/docs/developers/operations/akamai-well-known-forward.md b/docs/developers/operations/akamai-well-known-forward.md new file mode 100644 index 000000000..06d61eac0 --- /dev/null +++ b/docs/developers/operations/akamai-well-known-forward.md @@ -0,0 +1,33 @@ +# Akamai edge: forward `/.well-known/*` to origin + +## Problem + +On `developers.sap.com`, Akamai returns **403** for every `/.well-known/*` path +except `security.txt` (verified: `Server: AkamaiGHost` on the 403). The origin +approuter serves the MCP OAuth discovery documents correctly, but the edge blocks +them before they reach origin, so MCP clients cannot auto-discover the server. + +## Request to the Akamai/edge team + +Forward the following origin paths on `developers.sap.com` to the approuter origin +(pass-through, no edge auth, cacheable per origin `Cache-Control`): + +- `/.well-known/oauth-authorization-server` +- `/.well-known/oauth-protected-resource` +- `/.well-known/openid-configuration` +- `/.well-known/mcp.json` +- `/.well-known/security.txt` (keep working; origin now also serves it) + +Simplest rule: forward the whole `/.well-known/*` prefix to origin. + +## Verification after the rule lands + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://developers.sap.com/.well-known/oauth-authorization-server # expect 200 +curl -s https://developers.sap.com/.well-known/oauth-protected-resource | jq . +curl -s -D - -o /dev/null https://developers.sap.com/mcp-auth/api | grep -i www-authenticate # expect resource_metadata pointer +``` + +The `/mcp-auth/api` check exercises a separate path (`/mcp-auth/*`, which already reaches origin) and is not covered by the requested `/.well-known/*` edge-forward rule; it is included only to confirm the `WWW-Authenticate` discovery pointer end-to-end. + +DEV has no Akamai in front, so all paths work there as soon as the approuter deploys. diff --git a/docs/superpowers/plans/2026-08-28-well-known-discovery-additions.md b/docs/superpowers/plans/2026-08-28-well-known-discovery-additions.md new file mode 100644 index 000000000..8ea2cbe21 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-well-known-discovery-additions.md @@ -0,0 +1,547 @@ +# `.well-known` Discovery Additions — 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 the four remaining `.well-known` discovery pieces (`openid-configuration`, `mcp.json`, a `WWW-Authenticate` discovery pointer, and the PROD Akamai edge-forward request) on top of the already-shipped Option-A OAuth/security.txt surface. + +**Architecture:** The OAuth discovery + security.txt docs already ship as runtime approuter middleware (`approuter/lib/well-known-oauth.js`, `approuter/lib/security-txt.js`), registered `insertMiddleware.first` in `approuter/server.js`. New work mirrors those exact patterns — small CommonJS middleware modules, VCAP/request-derived values, GET/HEAD-only, pass-through otherwise — plus a docs fix and an ops ticket. + +**Tech Stack:** Node.js CommonJS (approuter middleware), `@sap/approuter`, Vitest (`unit` + `hybrid` projects), no new dependencies. + +**Spec:** `docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md` + +## Global Constraints + +- **Keep Option A.** Do NOT change `authorization_servers`/`issuer` (they point at the XSUAA URL) or the fully-qualified scope logic in `resolveScope()`. +- **No static files, no envsubst.** All values derive at runtime from `VCAP_SERVICES.xsuaa[0].credentials` and the request host (`resolveBaseUrl`). +- **Middleware discipline:** GET/HEAD only where serving a document; every non-matching path/method calls `next()` (never swallows other routes). Register new handlers in `approuter/server.js` `insertMiddleware.first`, before `staticHandler`/`proxyHandler`. +- **Scope string:** always the qualified form via `resolveScope()` (`.Tutorial.MCP`) — bare `Tutorial.MCP` is rejected by XSUAA. +- Run unit tests from repo root: `npx vitest run --project unit `. + +--- + +### Task 1: `/.well-known/openid-configuration` alias + +**Files:** +- Modify: `approuter/lib/well-known-oauth.js` +- Test: `test/unit/well-known-oauth.test.js` (create) + +**Interfaces:** +- Consumes: existing `resolveIssuer()`, `resolveScope()`, `authorizationServerMetadata(issuer, scope)`, `AUTH_SERVER_PATH` in the same module. +- Produces: new export `OPENID_CONFIG_PATH = '/.well-known/openid-configuration'`; `wellKnownOAuthHandler` now also answers that path with the auth-server metadata body. + +- [ ] **Step 1: Write the failing test** — create `test/unit/well-known-oauth.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const XSUAA = { xsuaa: [{ credentials: { + url: 'https://tenant.authentication.eu10-005.hana.ondemand.com', + xsappname: 'tutorials!t676072', +} }] }; + +function mockRes() { + return { + statusCode: null, headers: null, body: null, + writeHead(s, h) { this.statusCode = s; this.headers = h; return this; }, + end(p) { this.body = p; return this; }, + }; +} + +let mod, prevVcap; +beforeAll(() => { prevVcap = process.env.VCAP_SERVICES; process.env.VCAP_SERVICES = JSON.stringify(XSUAA); + mod = require('../../approuter/lib/well-known-oauth.js'); }); +afterAll(() => { if (prevVcap === undefined) delete process.env.VCAP_SERVICES; else process.env.VCAP_SERVICES = prevVcap; }); + +describe('well-known-oauth: openid-configuration alias', () => { + it('serves openid-configuration with the same body as oauth-authorization-server', () => { + const { wellKnownOAuthHandler, OPENID_CONFIG_PATH, authorizationServerMetadata, resolveScope } = mod; + expect(OPENID_CONFIG_PATH).toBe('/.well-known/openid-configuration'); + const res = mockRes(); + let nexted = false; + wellKnownOAuthHandler({ method: 'GET', url: OPENID_CONFIG_PATH, headers: {} }, res, () => { nexted = true; }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(200); + const doc = JSON.parse(res.body); + expect(doc).toEqual(authorizationServerMetadata( + 'https://tenant.authentication.eu10-005.hana.ondemand.com', resolveScope())); + expect(doc.code_challenge_methods_supported).toContain('S256'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/well-known-oauth.test.js` +Expected: FAIL — `OPENID_CONFIG_PATH` is `undefined` / path falls through to `next()`. + +- [ ] **Step 3: Implement** — in `approuter/lib/well-known-oauth.js`: + +Add near the other path constants: +```js +const OPENID_CONFIG_PATH = '/.well-known/openid-configuration' +``` +Change the path guard in `wellKnownOAuthHandler` from: +```js +if (pathOnly !== AUTH_SERVER_PATH && pathOnly !== PROTECTED_RESOURCE_PATH) { + return next() +} +``` +to include the alias: +```js +if (pathOnly !== AUTH_SERVER_PATH && pathOnly !== PROTECTED_RESOURCE_PATH && pathOnly !== OPENID_CONFIG_PATH) { + return next() +} +``` +Change the auth-server branch from: +```js +if (pathOnly === AUTH_SERVER_PATH) { + return sendJson(res, 200, authorizationServerMetadata(issuer, scope)) +} +``` +to: +```js +if (pathOnly === AUTH_SERVER_PATH || pathOnly === OPENID_CONFIG_PATH) { + return sendJson(res, 200, authorizationServerMetadata(issuer, scope)) +} +``` +Add `OPENID_CONFIG_PATH` to `module.exports`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/well-known-oauth.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add approuter/lib/well-known-oauth.js test/unit/well-known-oauth.test.js +git commit -m "feat(approuter): serve /.well-known/openid-configuration as oauth-authorization-server alias" +``` + +--- + +### Task 2: `/.well-known/mcp.json` courtesy manifest + +**Files:** +- Create: `approuter/lib/well-known-mcp-manifest.js` +- Modify: `approuter/server.js` (require + wire in `insertMiddleware.first`) +- Test: `test/unit/well-known-mcp-manifest.test.js` (create) + +**Interfaces:** +- Consumes: `resolveBaseUrl(req)`, `resolveScope()` from `./well-known-oauth`. +- Produces: export `{ mcpManifestHandler, MCP_MANIFEST_PATH, buildManifest }`. + +- [ ] **Step 1: Write the failing test** — create `test/unit/well-known-mcp-manifest.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const XSUAA = { xsuaa: [{ credentials: { + url: 'https://tenant.authentication.eu10-005.hana.ondemand.com', + xsappname: 'tutorials!t676072', +} }] }; +function mockRes() { + return { statusCode: null, headers: null, body: null, + writeHead(s, h) { this.statusCode = s; this.headers = h; return this; }, + end(p) { this.body = p; return this; } }; +} +const REQ = { method: 'GET', url: '/.well-known/mcp.json', + headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'developers.sap.com' } }; + +let mod, prevVcap; +beforeAll(() => { prevVcap = process.env.VCAP_SERVICES; process.env.VCAP_SERVICES = JSON.stringify(XSUAA); + mod = require('../../approuter/lib/well-known-mcp-manifest.js'); }); +afterAll(() => { if (prevVcap === undefined) delete process.env.VCAP_SERVICES; else process.env.VCAP_SERVICES = prevVcap; }); + +describe('well-known-mcp-manifest', () => { + it('serves mcp.json with server list and qualified scope', () => { + const { mcpManifestHandler } = mod; + const res = mockRes(); + let nexted = false; + mcpManifestHandler(REQ, res, () => { nexted = true; }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(200); + expect(res.headers['Content-Type']).toMatch(/application\/json/); + const doc = JSON.parse(res.body); + expect(doc.servers.map(s => s.name)).toEqual(['search', 'homepage', 'graph', 'developer']); + const dev = doc.servers.find(s => s.name === 'developer'); + expect(dev.url).toBe('https://developers.sap.com/mcp-auth/api'); + expect(dev.scope).toBe('tutorials!t676072.Tutorial.MCP'); + expect(doc.authorization.protected_resource) + .toBe('https://developers.sap.com/.well-known/oauth-protected-resource'); + }); + + it('passes through non-matching paths and non-GET methods', () => { + const { mcpManifestHandler } = mod; + for (const req of [ + { method: 'GET', url: '/.well-known/other', headers: {} }, + { method: 'POST', url: '/.well-known/mcp.json', headers: {} }, + ]) { + const res = mockRes(); let nexted = false; + mcpManifestHandler(req, res, () => { nexted = true; }); + expect(nexted).toBe(true); + expect(res.statusCode).toBeNull(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/well-known-mcp-manifest.test.js` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement** — create `approuter/lib/well-known-mcp-manifest.js`: + +```js +// approuter/lib/well-known-mcp-manifest.js +// +// Serves /.well-known/mcp.json — a NON-STANDARD convenience manifest listing the +// hosted MCP endpoints. NOT part of the MCP spec (server publishing is via the +// central MCP Registry's server.json); we serve it as a courtesy. Runtime +// middleware, mirroring well-known-oauth.js / security-txt.js. Base URL from the +// request; scope from the bound XSUAA binding (qualified form). + +const { resolveBaseUrl, resolveScope } = require('./well-known-oauth') + +const MCP_MANIFEST_PATH = '/.well-known/mcp.json' + +function buildManifest(baseUrl, scope) { + return { + $comment: 'Non-standard convenience manifest; not part of the MCP specification.', + name: 'SAP Developers MCP', + provider: 'SAP Tutorials (developers.sap.com)', + servers: [ + { name: 'search', url: `${baseUrl}/mcp/search`, auth: 'none' }, + { name: 'homepage', url: `${baseUrl}/mcp/homepage`, auth: 'none' }, + { name: 'graph', url: `${baseUrl}/mcp/graph`, auth: 'none' }, + { name: 'developer', url: `${baseUrl}/mcp-auth/api`, auth: 'oauth2', scope }, + ], + authorization: { protected_resource: `${baseUrl}/.well-known/oauth-protected-resource` }, + } +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' }) + res.end(JSON.stringify(body, null, 2)) +} + +function mcpManifestHandler(req, res, next) { + if (req.method !== 'GET' && req.method !== 'HEAD') return next() + const pathOnly = (req.url || '').split('?')[0] + if (pathOnly !== MCP_MANIFEST_PATH) return next() + + const baseUrl = resolveBaseUrl(req) + if (!baseUrl) return sendJson(res, 503, { error: 'mcp_manifest_unavailable' }) + return sendJson(res, 200, buildManifest(baseUrl, resolveScope())) +} + +module.exports = { mcpManifestHandler, MCP_MANIFEST_PATH, buildManifest } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/well-known-mcp-manifest.test.js` +Expected: PASS. + +- [ ] **Step 5: Wire into the approuter** — in `approuter/server.js`, near the existing requires (`const { wellKnownOAuthHandler } = require('./lib/well-known-oauth')`): + +```js +const { mcpManifestHandler } = require('./lib/well-known-mcp-manifest') +``` +In the `insertMiddleware.first` array, add it right after the oauth handler: +```js + first: [ + { path: '/', handler: wellKnownOAuthHandler }, + { path: '/', handler: mcpManifestHandler }, + { path: '/', handler: securityTxtHandler }, + // …rest unchanged… +``` + +- [ ] **Step 6: Commit** + +```bash +git add approuter/lib/well-known-mcp-manifest.js approuter/server.js test/unit/well-known-mcp-manifest.test.js +git commit -m "feat(approuter): serve /.well-known/mcp.json courtesy manifest" +``` + +--- + +### Task 3: `WWW-Authenticate` discovery pointer on `/mcp-auth` 401 + +**Files:** +- Create: `approuter/lib/mcp-auth-challenge.js` +- Modify: `approuter/server.js` (require + wire) +- Test: `test/unit/mcp-auth-challenge.test.js` (create) + +**Interfaces:** +- Consumes: `resolveBaseUrl(req)`, `resolveScope()` from `./well-known-oauth`. +- Produces: export `{ mcpAuthChallengeHandler, MCP_AUTH_PREFIXES }`. + +- [ ] **Step 1: Spike (record findings in the commit body).** Confirm in `approuter/server.js` that `/mcp-auth/*` and `/mcp-admin/*` are proxied via `xs-app.json` XSUAA routes and that `insertMiddleware.first` runs before that auth. A request WITHOUT an `Authorization` header currently 401s (or 302-redirects) at the approuter with no discovery pointer. The handler short-circuits ONLY the no-`Authorization`-header case, so a valid bearer always passes through untouched. If a browser-cookie session (no bearer) legitimately hits `/mcp-auth`, that is out of scope (this namespace is programmatic MCP only) — note it and proceed. + +- [ ] **Step 2: Write the failing test** — create `test/unit/mcp-auth-challenge.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const XSUAA = { xsuaa: [{ credentials: { + url: 'https://tenant.authentication.eu10-005.hana.ondemand.com', + xsappname: 'tutorials!t676072', +} }] }; +function mockRes() { + return { statusCode: null, headers: {}, body: null, + setHeader(k, v) { this.headers[k] = v; }, + writeHead(s, h) { this.statusCode = s; Object.assign(this.headers, h || {}); return this; }, + end(p) { this.body = p; return this; } }; +} +let mod, prevVcap; +beforeAll(() => { prevVcap = process.env.VCAP_SERVICES; process.env.VCAP_SERVICES = JSON.stringify(XSUAA); + mod = require('../../approuter/lib/mcp-auth-challenge.js'); }); +afterAll(() => { if (prevVcap === undefined) delete process.env.VCAP_SERVICES; else process.env.VCAP_SERVICES = prevVcap; }); + +describe('mcp-auth-challenge', () => { + it('401s with a resource_metadata pointer when no bearer on /mcp-auth', () => { + const { mcpAuthChallengeHandler } = mod; + const res = mockRes(); let nexted = false; + mcpAuthChallengeHandler( + { method: 'POST', url: '/mcp-auth/api', + headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'developers.sap.com' } }, + res, () => { nexted = true; }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(401); + expect(res.headers['WWW-Authenticate']).toBe( + 'Bearer resource_metadata="https://developers.sap.com/.well-known/oauth-protected-resource", scope="tutorials!t676072.Tutorial.MCP"'); + }); + + it('passes through when an Authorization bearer is present', () => { + const { mcpAuthChallengeHandler } = mod; + const res = mockRes(); let nexted = false; + mcpAuthChallengeHandler( + { method: 'POST', url: '/mcp-auth/api', headers: { authorization: 'Bearer abc' } }, + res, () => { nexted = true; }); + expect(nexted).toBe(true); + expect(res.statusCode).toBeNull(); + }); + + it('ignores unrelated paths', () => { + const { mcpAuthChallengeHandler } = mod; + const res = mockRes(); let nexted = false; + mcpAuthChallengeHandler({ method: 'GET', url: '/tutorials/foo', headers: {} }, res, () => { nexted = true; }); + expect(nexted).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/mcp-auth-challenge.test.js` +Expected: FAIL — module does not exist. + +- [ ] **Step 4: Implement** — create `approuter/lib/mcp-auth-challenge.js`: + +```js +// approuter/lib/mcp-auth-challenge.js +// +// Emits the MCP-spec-preferred discovery trigger: a 401 with a +// `WWW-Authenticate: Bearer resource_metadata="…"` pointer on the protected MCP +// namespaces, so compliant clients follow the pointer to the protected-resource +// metadata instead of blindly probing /.well-known. Runtime middleware; mirrors +// the srv-side /mcp-pat short-circuit in srv/server.js. Only fires when NO +// Authorization header is present, so a valid bearer always passes through. + +const { resolveBaseUrl, resolveScope } = require('./well-known-oauth') + +const MCP_AUTH_PREFIXES = ['/mcp-auth', '/mcp-admin'] + +function matchesProtectedMcp(pathOnly) { + return MCP_AUTH_PREFIXES.some(p => pathOnly === p || pathOnly.startsWith(p + '/')) +} + +function mcpAuthChallengeHandler(req, res, next) { + const pathOnly = (req.url || '').split('?')[0] + if (!matchesProtectedMcp(pathOnly)) return next() + + const authz = req.headers && req.headers.authorization + if (authz && authz.startsWith('Bearer ')) return next() + + const baseUrl = resolveBaseUrl(req) + const scope = resolveScope() + if (baseUrl) { + res.setHeader('WWW-Authenticate', + `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="${scope}"`) + } + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'unauthorized', error_description: 'Bearer token required.' })) +} + +module.exports = { mcpAuthChallengeHandler, MCP_AUTH_PREFIXES } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/mcp-auth-challenge.test.js` +Expected: PASS. + +- [ ] **Step 6: Wire into the approuter** — in `approuter/server.js`, add the require: +```js +const { mcpAuthChallengeHandler } = require('./lib/mcp-auth-challenge') +``` +In `insertMiddleware.first`, add it after the well-known handlers and before `staticHandler`/`proxyHandler`: +```js + { path: '/', handler: mcpAuthChallengeHandler }, +``` + +- [ ] **Step 7: Commit** + +```bash +git add approuter/lib/mcp-auth-challenge.js approuter/server.js test/unit/mcp-auth-challenge.test.js +git commit -m "feat(approuter): add resource_metadata WWW-Authenticate pointer on /mcp-auth 401" +``` + +--- + +### Task 4: Extend the hybrid discovery test + +**Files:** +- Modify: `test/hybrid/oauth-discovery.test.js` + +**Interfaces:** consumes deployed approuter over `SMOKE_BASE_URL`/`HYBRID_APPROUTER_URL`; self-skips when unreachable (existing pattern). + +- [ ] **Step 1: Add assertions** — append two `it()` blocks inside the existing `describeIf`: + +```js + it('serves /.well-known/openid-configuration identical to oauth-authorization-server', async () => { + const [as, oidc] = await Promise.all([ + fetch(`${BASE}/.well-known/oauth-authorization-server`).then(r => r.json()), + fetch(`${BASE}/.well-known/openid-configuration`).then(r => r.json()), + ]); + expect(oidc).toEqual(as); + }); + + it('serves /.well-known/mcp.json with the four servers', async () => { + const res = await fetch(`${BASE}/.well-known/mcp.json`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/application\/json/); + const doc = await res.json(); + expect(doc.servers.map(s => s.name)).toEqual(['search', 'homepage', 'graph', 'developer']); + expect(doc.authorization.protected_resource).toContain('/.well-known/oauth-protected-resource'); + }); +``` + +- [ ] **Step 2: Run (expected skip locally)** + +Run: `npx vitest run --project hybrid test/hybrid/oauth-discovery.test.js` +Expected: SKIP (target unreachable without `SMOKE_BASE_URL`) — confirms the file parses and the suite loads. Live assertion happens post-deploy against the real approuter host (see Task 6). + +- [ ] **Step 3: Commit** + +```bash +git add test/hybrid/oauth-discovery.test.js +git commit -m "test(hybrid): assert openid-configuration + mcp.json on deployed approuter" +``` + +--- + +### Task 5: Fix the architecture doc + draft the Akamai edge ticket + +**Files:** +- Modify: `docs/developers/architecture/mcp-server.md` (§`.well-known` discovery) +- Create: `docs/developers/operations/akamai-well-known-forward.md` + +- [ ] **Step 1: Correct the architecture doc.** Replace the §`.well-known` discovery paragraph that claims static files (`approuter/static/.well-known/`, "substituted at deploy time from `deploy/*.mtaext`") with the reality: + +```markdown +## `.well-known` discovery + +Discovery documents are served as **runtime approuter middleware** +(`insertMiddleware.first` in `approuter/server.js`), NOT static files — values +derive at request time from the bound XSUAA VCAP credentials and the request +host, so they are correct in every environment with no build-time substitution. + +- `approuter/lib/well-known-oauth.js` — `/.well-known/oauth-authorization-server` + (RFC 8414), its alias `/.well-known/openid-configuration`, and + `/.well-known/oauth-protected-resource` (RFC 9728). `authorization_servers`/ + `issuer` point at the XSUAA URL (Option A); `scopes_supported` advertises the + fully-qualified `.Tutorial.MCP` (bare `Tutorial.MCP` is rejected by + XSUAA with `invalid_scope`). +- `approuter/lib/well-known-mcp-manifest.js` — `/.well-known/mcp.json`, a + non-standard courtesy manifest listing the MCP mounts. Not part of the MCP spec. +- `approuter/lib/security-txt.js` — `/.well-known/security.txt` (RFC 9116). +- `approuter/lib/mcp-auth-challenge.js` — a 401 on `/mcp-auth/*` and `/mcp-admin/*` + without a bearer carries `WWW-Authenticate: Bearer resource_metadata="…"`, the + MCP-preferred discovery trigger. + +**Edge note:** on `developers.sap.com`, Akamai 403s `/.well-known/*` at the edge +except `security.txt`. The edge must forward these paths to origin — see +[operations/akamai-well-known-forward.md](../operations/akamai-well-known-forward.md). +``` + +- [ ] **Step 2: Create the Akamai request doc** — `docs/developers/operations/akamai-well-known-forward.md`: + +```markdown +# Akamai edge: forward `/.well-known/*` to origin + +## Problem + +On `developers.sap.com`, Akamai returns **403** for every `/.well-known/*` path +except `security.txt` (verified: `Server: AkamaiGHost` on the 403). The origin +approuter serves the MCP OAuth discovery documents correctly, but the edge blocks +them before they reach origin, so MCP clients cannot auto-discover the server. + +## Request to the Akamai/edge team + +Forward the following origin paths on `developers.sap.com` to the approuter origin +(pass-through, no edge auth, cacheable per origin `Cache-Control`): + +- `/.well-known/oauth-authorization-server` +- `/.well-known/oauth-protected-resource` +- `/.well-known/openid-configuration` +- `/.well-known/mcp.json` +- `/.well-known/security.txt` (keep working; origin now also serves it) + +Simplest rule: forward the whole `/.well-known/*` prefix to origin. + +## Verification after the rule lands + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://developers.sap.com/.well-known/oauth-authorization-server # expect 200 +curl -s https://developers.sap.com/.well-known/oauth-protected-resource | jq . +curl -s -D - -o /dev/null https://developers.sap.com/mcp-auth/api | grep -i www-authenticate # expect resource_metadata pointer +``` + +DEV has no Akamai in front, so all paths work there as soon as the approuter deploys. +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/architecture/mcp-server.md docs/developers/operations/akamai-well-known-forward.md +git commit -m "docs: correct .well-known architecture + add Akamai edge-forward request" +``` + +--- + +### Task 6: Deploy to DEV and verify live (needs Tom's cf session) + +**Files:** none (verification only). + +- [ ] **Step 1:** Get the correct DEV approuter route from Tom: `cf routes` (the hybrid test's hardcoded fallback `tutorials-approuter-dev.cfapps.eu10-005…` is stale — its root 404s). Deploy the approuter per the CLAUDE.md canonical local deploy (full `build:all` → `mbt build` → `cf deploy … -e ../deploy/dev.mtaext`). +- [ ] **Step 2:** `curl` all five `/.well-known/*` paths against the real DEV approuter → expect 200 + correct bodies; `curl -D-` `/mcp-auth/api` → 401 with the `resource_metadata` pointer. +- [ ] **Step 3:** Run the hybrid suite against DEV: `SMOKE_BASE_URL= npx vitest run --project hybrid test/hybrid/oauth-discovery.test.js` → PASS (no longer skipped). +- [ ] **Step 4:** File the Akamai ticket from Task 5's doc for the PROD path. + +--- + +## Self-Review + +- **Spec coverage:** openid-configuration (Task 1), mcp.json (Task 2), WWW-Authenticate pointer (Task 3), hybrid tests (Task 4), doc fix + Akamai ticket (Task 5), live DEV verify (Task 6). All four spec items covered. Option A untouched (global constraint). +- **Placeholder scan:** none — every code/test block is concrete. +- **Type consistency:** `resolveBaseUrl`/`resolveScope` are the real exports of `well-known-oauth.js` (verified). New exports (`OPENID_CONFIG_PATH`, `mcpManifestHandler`, `mcpAuthChallengeHandler`, `MCP_AUTH_PREFIXES`) are consistent across their defining task and the `server.js` wiring. diff --git a/docs/superpowers/specs/2026-05-23-tutorials-qa-endpoint-design.md b/docs/superpowers/specs/2026-05-23-tutorials-qa-endpoint-design.md index 581327d7b..0d28910ea 100644 --- a/docs/superpowers/specs/2026-05-23-tutorials-qa-endpoint-design.md +++ b/docs/superpowers/specs/2026-05-23-tutorials-qa-endpoint-design.md @@ -126,8 +126,10 @@ features are out of scope and must not be reachable through the QA surface. - `scripts/publish-content.ts` — add `--channel ` flag. In `qa` mode: - `CAP_BASE_URL` defaults to `CAP_QA_BASE_URL` env. - Bearer token reads from `CONTENT_API_KEY_QA`. - - Always uses delta detection bypass (effectively `--force` semantics — - matches the existing prod gotcha). + - Delta by default (mirrors prod). `PUBLISH_SLUG` (dispatch slug) scopes a + commit-triggered rebuild to the changed slug; `--force` (workflow + `force-publish` input) does a full re-seed. Superseded the original + "always --force" design once QA gained Option B (mutable ContentCurrent). - Source dir: `hugo/public-qa/`. - `scripts/install-qa-workflows.ts` — one-shot installer that opens a PR adding `.github/workflows/notify-qa.yml` to each `*-Contribution` repo. Idempotent; @@ -325,5 +327,7 @@ are deferred to deploy time. mirrors. - `scripts/parsers/github.ts:432,477` — existing `INCLUDE_CONTRIBUTION_REPOS` flag; QA uses inverse `ONLY_CONTRIBUTION_REPOS` semantic. -- `feedback_publish_content_force.md` — published Tom-memory; QA always uses - force-publish for the same reason. +- `feedback_publish_content_force.md` — published Tom-memory; originally QA + always force-published. Superseded: QA is now delta by default with + `PUBLISH_SLUG` scoping (safe once Option B landed); `--force` reserved for + full re-seeds. diff --git a/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md b/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md new file mode 100644 index 000000000..3ce6ae4a8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-well-known-oauth-discovery-design.md @@ -0,0 +1,79 @@ +# `.well-known` discovery — remaining additions for the hosted MCP server + +**Date:** 2026-08-28 (revised after discovering the OAuth docs already ship) +**Decision:** Keep the shipped **Option A** OAuth discovery. Add the four missing pieces below. + +## Correction to the original premise + +An earlier draft of this spec assumed nothing was served and proposed building an Option-B surface at the CAP origin. That was wrong — a broken `rg` silently returned no matches. **The OAuth discovery + security.txt surface already exists, wired, and tested:** + +- `approuter/lib/well-known-oauth.js` → `wellKnownOAuthHandler` serves `/.well-known/oauth-authorization-server` (RFC 8414) and `/.well-known/oauth-protected-resource` (RFC 9728), **dynamically at runtime** from the bound XSUAA VCAP credentials (no static files, no envsubst). +- `approuter/lib/security-txt.js` → `securityTxtHandler` serves `/.well-known/security.txt` (RFC 9116), byte-identical to SAP's canonical, guarded by a drift check. +- Both are registered as `insertMiddleware.first` in `approuter/server.js` (they answer before the `^/.well-known/(.*)$` → `srv-api` proxy route, which serves neither). +- Tests: `test/unit/security-txt.test.js`, `test/hybrid/oauth-discovery.test.js`. + +**Option A is retained** (`authorization_servers`/`issuer` point straight at the XSUAA URL; the docs advertise the *fully-qualified* scope `.Tutorial.MCP`, e.g. `tutorials!t676072.Tutorial.MCP` — bare `Tutorial.MCP` is rejected by XSUAA with `invalid_scope`; `resolveScope()` encodes this hard-won behavior). Do **not** rewrite to Option B. + +**The real reason the docs are unreachable on `developers.sap.com`:** Akamai 403s every `/.well-known/*` path at the edge except `security.txt` (confirmed: `Server: AkamaiGHost` on the 403). The origin serves them correctly; the edge blocks them. + +## Scope (four additions) + +### 1. `/.well-known/mcp.json` (non-standard courtesy manifest) + +Not part of the MCP spec (server publishing is via the central MCP Registry `server.json`). Served anyway as a convenience. New approuter middleware mirroring `security-txt.js` / `well-known-oauth.js`. Body (base URL derived from the request, same `resolveBaseUrl` approach as the oauth handler): + +```json +{ + "$comment": "Non-standard convenience manifest; not part of the MCP specification.", + "name": "SAP Developers MCP", + "provider": "SAP Tutorials (developers.sap.com)", + "servers": [ + { "name": "search", "url": "/mcp/search", "auth": "none" }, + { "name": "homepage", "url": "/mcp/homepage", "auth": "none" }, + { "name": "graph", "url": "/mcp/graph", "auth": "none" }, + { "name": "developer", "url": "/mcp-auth/api", "auth": "oauth2", "scope": "" } + ], + "authorization": { "protected_resource": "/.well-known/oauth-protected-resource" } +} +``` + +`Content-Type: application/json`, `Cache-Control: public, max-age=300`. Reuse `resolveScope()` for the qualified scope. GET/HEAD only; other methods + other paths pass through to `next()`. + +### 2. `/.well-known/openid-configuration` (alias of oauth-authorization-server) + +Native/draft-spec MCP clients try OIDC discovery when RFC 8414 isn't found. Serve the **same body** as `authorization_server_metadata`. Cleanest: extend `wellKnownOAuthHandler` to also match `OPENID_CONFIG_PATH = '/.well-known/openid-configuration'` and return `authorizationServerMetadata(issuer, scope)`. The 503-when-no-issuer path is shared. + +### 3. `WWW-Authenticate: … resource_metadata="…"` pointer + +The spec's *preferred* discovery trigger. The three existing 401s (`srv/server.js:764`, `srv/lib/a2a/rpc-router.js:23`, `srv/lib/mcp-pat-middleware.js:92`) set only `Bearer error="…"` with no pointer. The primary protected resource is `/mcp-auth/*`, whose 401 is emitted by the **approuter** (XSUAA auth), so the pointer belongs in an approuter middleware. + +**Implementation:** a small approuter `insertMiddleware.first` handler that, for `/mcp-auth/*` and `/mcp-admin/*` requests lacking an `Authorization: Bearer` header, short-circuits with `401` + header (mirrors how `srv/server.js:756` short-circuits `/mcp-pat/*`): + +``` +WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource", scope="" +``` + +**Open item (spike first):** confirm the approuter forwards `/mcp-auth/*` to XSUAA auth *after* `insertMiddleware.first`, so short-circuiting there is safe and doesn't break the valid-bearer path. If short-circuit is unsafe, fall back to augmenting the CAP `/mcp/*` 401 (`srv/server.js`) with the same pointer and document that `/mcp-auth` 401s carry only the bare header. + +### 4. Akamai edge-forward rule (PROD blocker — not code) + +Draft a networking/edge request to forward `/.well-known/*` (or at minimum the five paths: `oauth-authorization-server`, `oauth-protected-resource`, `openid-configuration`, `mcp.json`, and keep `security.txt`) from the edge to origin on `developers.sap.com`. Deliverable: a written ticket/request in the PR description or an ops doc, not a code change. Everything else works behind Akamai the moment this lands; DEV (no Akamai) works immediately. + +## Docs to fix + +`docs/developers/architecture/mcp-server.md` §`.well-known` discovery still claims these are **static** files under `approuter/static/.well-known/`. Correct it to describe the runtime middleware (`approuter/lib/well-known-oauth.js`) and add the two new docs. `docs/end-users/mcp-quickstart.md` is broadly accurate (it already describes discovery from `oauth-authorization-server`); add an `openid-configuration` mention only if needed. + +## Testing + +- **Unit** (`test/unit/`): new `mcp.json` handler — 200 + JSON + expected `servers[]`/scope, passes through non-matching paths & methods (mirror `security-txt.test.js`). `wellKnownOAuthHandler` — `openid-configuration` returns the same body as `oauth-authorization-server`; 503 when no issuer. WWW-Authenticate middleware — 401 + `resource_metadata` pointer when no bearer on `/mcp-auth`, pass-through when bearer present. +- **Hybrid** (`test/hybrid/oauth-discovery.test.js`): extend to assert `openid-configuration` + `mcp.json` shape against the deployed approuter (uses `SMOKE_BASE_URL`). +- **Live DEV** (manual, needs correct approuter host via `cf`): `curl` all five paths → 200; `curl -D-` `/mcp-auth/api` → 401 carries `resource_metadata`. + +## Out of scope + +Rewriting to Option B; MCP Registry publishing; changing XSUAA client registration/DCR. + +## Open items to confirm during implementation + +- Correct **live DEV approuter hostname** (the hybrid test's hardcoded fallback `tutorials-approuter-dev.cfapps.eu10-005…` is stale — root 404s). Get it from Tom's `cf routes`. +- Whether short-circuiting `/mcp-auth/*` in `insertMiddleware.first` is safe (item 3 spike). diff --git a/hugo-apps/src/me/AllCompletions.vue b/hugo-apps/src/me/AllCompletions.vue index 58a31b029..0fe6fa538 100644 --- a/hugo-apps/src/me/AllCompletions.vue +++ b/hugo-apps/src/me/AllCompletions.vue @@ -93,16 +93,34 @@ function itemUrl(r: Completion): string { function clearFilters() { filterText.value = ''; filterTopic.value = ''; filterLevel.value = '' } +// The approuter serves a lapsed/anonymous session as 200 + an XSUAA +// login-redirect HTML page (NOT 401), and Akamai can serve a cached anon +// /auth/user to a signed-in browser. A truthy signal requires JSON + +// body.authenticated — never authRes.ok alone. Mirrors coordinator.ts. +async function isSignedIn(): Promise { + try { + const r = await fetch('/auth/user', { credentials: 'include' }) + if (!r.ok) return false + if (!(r.headers.get('content-type') || '').includes('json')) return false + const body = await r.json() + return !!body?.authenticated + } catch { return false } +} + onMounted(async () => { try { - const authRes = await fetch('/auth/user', { credentials: 'include' }) - if (!authRes.ok) { isLoggedIn.value = false; loading.value = false; return } + if (!(await isSignedIn())) { isLoggedIn.value = false; loading.value = false; return } isLoggedIn.value = true const dataRes = await fetch('/api/getMyCompletions()', { credentials: 'include' }) if (!dataRes.ok) { errorMsg.value = `Failed to load completions (HTTP ${dataRes.status}).` loading.value = false; return } + // Session may have lapsed after the gate → 200 + HTML login page. Treat a + // non-JSON body as signed out, not a data error. + if (!(dataRes.headers.get('content-type') || '').includes('json')) { + isLoggedIn.value = false; loading.value = false; return + } const body = await dataRes.json() rows.value = Array.isArray(body) ? body : (body.value || []) } catch { diff --git a/hugo-apps/src/me/RecentActivity.vue b/hugo-apps/src/me/RecentActivity.vue index 4ff99b476..e6ba9c26f 100644 --- a/hugo-apps/src/me/RecentActivity.vue +++ b/hugo-apps/src/me/RecentActivity.vue @@ -73,16 +73,34 @@ function onTimelineNameClick(item: Completion) { window.location.href = itemUrl(item) } +// The approuter serves a lapsed/anonymous session as 200 + an XSUAA +// login-redirect HTML page (NOT 401), and Akamai can serve a cached anon +// /auth/user to a signed-in browser. A truthy signal requires JSON + +// body.authenticated — never authRes.ok alone. Mirrors coordinator.ts. +async function isSignedIn(): Promise { + try { + const r = await fetch('/auth/user', { credentials: 'include' }) + if (!r.ok) return false + if (!(r.headers.get('content-type') || '').includes('json')) return false + const body = await r.json() + return !!body?.authenticated + } catch { return false } +} + onMounted(async () => { try { - const authRes = await fetch('/auth/user', { credentials: 'include' }) - if (!authRes.ok) { isLoggedIn.value = false; loading.value = false; return } + if (!(await isSignedIn())) { isLoggedIn.value = false; loading.value = false; return } isLoggedIn.value = true const dataRes = await fetch('/api/getMyCompletions()', { credentials: 'include' }) if (!dataRes.ok) { errorMsg.value = `Failed to load recent activity (HTTP ${dataRes.status}).` loading.value = false; return } + // Session may have lapsed after the gate → 200 + HTML login page. Treat a + // non-JSON body as signed out, not a data error. + if (!(dataRes.headers.get('content-type') || '').includes('json')) { + isLoggedIn.value = false; loading.value = false; return + } const body = await dataRes.json() rows.value = Array.isArray(body) ? body : (body.value || []) } catch { diff --git a/hugo-apps/src/me/__tests__/AllCompletions.test.ts b/hugo-apps/src/me/__tests__/AllCompletions.test.ts new file mode 100644 index 000000000..8e2e20468 --- /dev/null +++ b/hugo-apps/src/me/__tests__/AllCompletions.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import AllCompletions from '../AllCompletions.vue'; + +// Keyed fetch mock. A route result may set `contentType` (defaults to JSON) so a +// test can simulate the approuter's 200 + HTML login-redirect page for a lapsed +// session — the exact case the resp.ok-only gate mishandled. +function mockFetch(routes: Record Promise>) { + return vi.fn(async (url: string, init?: RequestInit) => { + const key = `${init?.method || 'GET'} ${url}`; + const handler = routes[key] ?? routes[url]; + if (!handler) throw new Error(`unmocked: ${key}`); + const result = await handler(); + const contentType = result.contentType ?? 'application/json'; + return { + ok: result.ok ?? true, + status: result.status ?? 200, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? contentType : null) }, + json: async () => result.body, + }; + }); +} + +const USER_OK = () => ({ body: { authenticated: true, name: 'Tom' } }); +const LOGIN_HTML = () => ({ ok: true, status: 200, contentType: 'text/html', body: undefined }); + +describe('AllCompletions.vue auth gate', () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + it('renders the signed-in list when authenticated', async () => { + const fetchMock = mockFetch({ + '/auth/user': USER_OK, + '/api/getMyCompletions()': () => ({ body: [{ slug: 'abc', title: 'ABC' }] }), + }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(AllCompletions); + await flushPromises(); + expect(wrapper.text()).not.toMatch(/not signed in/i); + expect(wrapper.text()).not.toMatch(/network error|failed to load/i); + }); + + it('shows the sign-in prompt on 401', async () => { + const fetchMock = mockFetch({ '/auth/user': () => ({ ok: false, status: 401, body: {} }) }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(AllCompletions); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + }); + + // Regression: lapsed session → 200 + HTML login page. Must show sign-in, not + // fall through to a data fetch and surface an error. + it('shows the sign-in prompt when /auth/user returns 200 + HTML', async () => { + const fetchMock = mockFetch({ '/auth/user': LOGIN_HTML, '/api/getMyCompletions()': LOGIN_HTML }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(AllCompletions); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + expect(wrapper.text()).not.toMatch(/network error|failed to load/i); + }); + + it('shows the sign-in prompt when /auth/user reports authenticated:false', async () => { + const fetchMock = mockFetch({ '/auth/user': () => ({ body: { authenticated: false } }) }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(AllCompletions); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + }); + + // Regression: session lapses after the gate; the data endpoint returns + // 200 + HTML. Fall back to the sign-in prompt, not a data error. + it('shows the sign-in prompt when the data fetch returns 200 + HTML', async () => { + const fetchMock = mockFetch({ '/auth/user': USER_OK, '/api/getMyCompletions()': LOGIN_HTML }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(AllCompletions); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + expect(wrapper.text()).not.toMatch(/network error|failed to load/i); + }); +}); diff --git a/hugo-apps/src/me/__tests__/RecentActivity.test.ts b/hugo-apps/src/me/__tests__/RecentActivity.test.ts new file mode 100644 index 000000000..05fc79d12 --- /dev/null +++ b/hugo-apps/src/me/__tests__/RecentActivity.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import RecentActivity from '../RecentActivity.vue'; + +// Keyed fetch mock. A route result may set `contentType` (defaults to JSON) so a +// test can simulate the approuter's 200 + HTML login-redirect page for a lapsed +// session — the exact case the resp.ok-only gate mishandled. +function mockFetch(routes: Record Promise>) { + return vi.fn(async (url: string, init?: RequestInit) => { + const key = `${init?.method || 'GET'} ${url}`; + const handler = routes[key] ?? routes[url]; + if (!handler) throw new Error(`unmocked: ${key}`); + const result = await handler(); + const contentType = result.contentType ?? 'application/json'; + return { + ok: result.ok ?? true, + status: result.status ?? 200, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? contentType : null) }, + json: async () => result.body, + }; + }); +} + +const USER_OK = () => ({ body: { authenticated: true, name: 'Tom' } }); +const LOGIN_HTML = () => ({ ok: true, status: 200, contentType: 'text/html', body: undefined }); + +describe('RecentActivity.vue auth gate', () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + it('renders the signed-in view when authenticated', async () => { + const fetchMock = mockFetch({ + '/auth/user': USER_OK, + '/api/getMyCompletions()': () => ({ body: [{ slug: 'abc', title: 'ABC' }] }), + }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(RecentActivity); + await flushPromises(); + expect(wrapper.text()).not.toMatch(/not signed in/i); + expect(wrapper.text()).not.toMatch(/network error|failed to load/i); + }); + + it('shows the sign-in prompt on 401', async () => { + const fetchMock = mockFetch({ '/auth/user': () => ({ ok: false, status: 401, body: {} }) }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(RecentActivity); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + }); + + // Regression: lapsed session → 200 + HTML login page. Must show sign-in, not + // fall through to a data fetch and surface an error. + it('shows the sign-in prompt when /auth/user returns 200 + HTML', async () => { + const fetchMock = mockFetch({ '/auth/user': LOGIN_HTML, '/api/getMyCompletions()': LOGIN_HTML }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(RecentActivity); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + expect(wrapper.text()).not.toMatch(/network error|failed to load/i); + }); + + it('shows the sign-in prompt when /auth/user reports authenticated:false', async () => { + const fetchMock = mockFetch({ '/auth/user': () => ({ body: { authenticated: false } }) }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(RecentActivity); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + }); + + // Regression: session lapses after the gate; the data endpoint returns + // 200 + HTML. Fall back to the sign-in prompt, not a data error. + it('shows the sign-in prompt when the data fetch returns 200 + HTML', async () => { + const fetchMock = mockFetch({ '/auth/user': USER_OK, '/api/getMyCompletions()': LOGIN_HTML }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(RecentActivity); + await flushPromises(); + expect(wrapper.text()).toMatch(/not signed in/i); + expect(wrapper.text()).not.toMatch(/network error|failed to load/i); + }); +}); diff --git a/hugo-apps/src/tokens/ApiTokens.vue b/hugo-apps/src/tokens/ApiTokens.vue index 7b3d1ce0c..9e2704ed8 100644 --- a/hugo-apps/src/tokens/ApiTokens.vue +++ b/hugo-apps/src/tokens/ApiTokens.vue @@ -134,6 +134,20 @@ function isRevocable(t: Pat): boolean { return t.revocable ?? !t.revokedAt; } +// The approuter returns 200 + an XSUAA login-redirect HTML page (NOT 401) for a +// lapsed/anonymous session, and Akamai can serve a cached anon /auth/user to a +// signed-in user. So a truthy signal requires JSON + body.authenticated — never +// resp.ok alone. Mirrors homepage-personalizer/coordinator.ts isSignedIn(). +async function isSignedIn(): Promise { + try { + const r = await fetch('/auth/user', { credentials: 'include' }); + if (!r.ok) return false; + if (!(r.headers.get('content-type') || '').includes('json')) return false; + const body = await r.json(); + return !!body?.authenticated; + } catch { return false; } +} + async function loadTokens() { loading.value = true; try { @@ -141,6 +155,13 @@ async function loadTokens() { headers: { Accept: 'application/json' }, credentials: 'include', }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + // Same 200-HTML-login-page hazard as the auth gate: if the session lapsed + // between the gate and here, /pats/MyPATs answers 200 + HTML. Treat a + // non-JSON body as an expired session (sign-in prompt), not a load error. + if (!(resp.headers.get('content-type') || '').includes('json')) { + needsLogin.value = true; + return; + } const data = await resp.json(); tokens.value = data.value ?? []; } catch { @@ -211,11 +232,9 @@ async function copyToken() { } onMounted(async () => { - // Gate on session like the other /me islands. 401/403 → sign-in prompt. - try { - const resp = await fetch('/auth/user', { credentials: 'include' }); - if (!resp.ok) { needsLogin.value = true; loading.value = false; return; } - } catch { + // Gate on session like the other /me islands. A lapsed session yields a 200 + // login-redirect HTML page (not 401), so check the parsed identity, not ok. + if (!(await isSignedIn())) { needsLogin.value = true; loading.value = false; return; } await loadTokens(); diff --git a/hugo-apps/src/tokens/__tests__/ApiTokens.test.ts b/hugo-apps/src/tokens/__tests__/ApiTokens.test.ts index 94066bec1..89d3ed87b 100644 --- a/hugo-apps/src/tokens/__tests__/ApiTokens.test.ts +++ b/hugo-apps/src/tokens/__tests__/ApiTokens.test.ts @@ -5,21 +5,29 @@ import ApiTokens from '../ApiTokens.vue'; import { _resetCsrfTokenCacheForTests, _seedCsrfTokenForTests } from '@shared/csrf-fetch'; // Keyed fetch mock: routes are looked up by "METHOD url" first, then by bare url. +// A route result may set `contentType` (defaults to application/json) so tests can +// simulate the approuter's 200 + HTML login-redirect page for a lapsed session. function mockFetch(routes: Record Promise>) { return vi.fn(async (url: string, init?: RequestInit) => { const key = `${init?.method || 'GET'} ${url}`; const handler = routes[key] ?? routes[url]; if (!handler) throw new Error(`unmocked: ${key}`); const result = await handler(); + const contentType = result.contentType ?? 'application/json'; return { ok: result.ok ?? true, status: result.status ?? 200, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? contentType : null) }, json: async () => result.body, }; }); } -const USER_OK = () => ({ body: { name: 'Tom', email: 'tom@example.com' } }); +// A signed-in /auth/user answer: JSON with authenticated:true (the real contract). +const USER_OK = () => ({ body: { authenticated: true, name: 'Tom', email: 'tom@example.com' } }); + +// The approuter's response for a lapsed/anonymous session: 200 + HTML login page. +const LOGIN_HTML = () => ({ ok: true, status: 200, contentType: 'text/html', body: undefined }); const TWO_TOKENS = () => ({ body: { @@ -77,6 +85,52 @@ describe('ApiTokens.vue', () => { expect(wrapper.find('[data-test="mint-btn"]').exists()).toBe(false); }); + // Regression: a lapsed session gets 200 + an XSUAA login-redirect HTML page, + // NOT a 401. The gate must treat non-JSON (and authenticated:false) as signed + // out — not fall through to loadTokens() and surface a load error. + it('shows a sign-in prompt when /auth/user returns 200 + HTML (lapsed session)', async () => { + const fetchMock = mockFetch({ + '/auth/user': LOGIN_HTML, + // If the gate wrongly fell through, this would be hit and (being HTML) + // would produce the "Couldn't load your tokens" error instead. + '/pats/MyPATs': LOGIN_HTML, + }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(ApiTokens); + await flushPromises(); + expect(wrapper.text()).toMatch(/sign in/i); + expect(wrapper.text()).not.toMatch(/couldn.?t load your tokens/i); + expect(wrapper.find('[data-test="mint-btn"]').exists()).toBe(false); + }); + + // Regression: /auth/user is JSON+ok but body.authenticated is false (e.g. an + // Akamai-cached anonymous identity served to a signed-in browser). + it('shows a sign-in prompt when /auth/user reports authenticated:false', async () => { + const fetchMock = mockFetch({ + '/auth/user': () => ({ body: { authenticated: false } }), + }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(ApiTokens); + await flushPromises(); + expect(wrapper.text()).toMatch(/sign in/i); + expect(wrapper.find('[data-test="mint-btn"]').exists()).toBe(false); + }); + + // Regression: session lapses between the gate and the list fetch, so + // /pats/MyPATs answers 200 + HTML. Fall back to the sign-in prompt, not the + // generic load error. + it('shows a sign-in prompt when /pats/MyPATs returns 200 + HTML', async () => { + const fetchMock = mockFetch({ + '/auth/user': USER_OK, + '/pats/MyPATs': LOGIN_HTML, + }); + vi.stubGlobal('fetch', fetchMock); + const wrapper = mount(ApiTokens); + await flushPromises(); + expect(wrapper.text()).toMatch(/sign in/i); + expect(wrapper.text()).not.toMatch(/couldn.?t load your tokens/i); + }); + it('mints a token and reveals the plaintext exactly once', async () => { let listCalls = 0; const fetchMock = mockFetch({ @@ -112,11 +166,12 @@ describe('ApiTokens.vue', () => { it('sends read+write scopes when the user selects write access', async () => { let sentBody: any = null; const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { - if (url === '/auth/user') return { ok: true, status: 200, json: async () => ({ name: 'Tom' }) }; - if (url === '/pats/MyPATs') return { ok: true, status: 200, json: async () => ({ value: [] }) }; + const jsonHeaders = { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'application/json' : null) }; + if (url === '/auth/user') return { ok: true, status: 200, headers: jsonHeaders, json: async () => ({ authenticated: true, name: 'Tom' }) }; + if (url === '/pats/MyPATs') return { ok: true, status: 200, headers: jsonHeaders, json: async () => ({ value: [] }) }; if (url === '/pats/mintPAT') { sentBody = JSON.parse(String(init?.body)); - return { ok: true, status: 200, json: async () => ({ ID: 'x', token: 'pat_x', prefix: 'pat_x', expiresAt: null }) }; + return { ok: true, status: 200, headers: jsonHeaders, json: async () => ({ ID: 'x', token: 'pat_x', prefix: 'pat_x', expiresAt: null }) }; } throw new Error(`unmocked: ${init?.method || 'GET'} ${url}`); }); @@ -138,9 +193,10 @@ describe('ApiTokens.vue', () => { let revokeUrl = ''; let listCalls = 0; const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { - if (url === '/auth/user') return { ok: true, status: 200, json: async () => ({ name: 'Tom' }) }; - if (url === '/pats/MyPATs') { listCalls++; return { ok: true, status: 200, json: async () => TWO_TOKENS().body }; } - if (url.includes('revokePAT')) { revokeUrl = url; return { ok: true, status: 200, json: async () => ({ ok: true, revokedAt: '2026-08-18T00:00:00Z' }) }; } + const jsonHeaders = { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'application/json' : null) }; + if (url === '/auth/user') return { ok: true, status: 200, headers: jsonHeaders, json: async () => ({ authenticated: true, name: 'Tom' }) }; + if (url === '/pats/MyPATs') { listCalls++; return { ok: true, status: 200, headers: jsonHeaders, json: async () => TWO_TOKENS().body }; } + if (url.includes('revokePAT')) { revokeUrl = url; return { ok: true, status: 200, headers: jsonHeaders, json: async () => ({ ok: true, revokedAt: '2026-08-18T00:00:00Z' }) }; } throw new Error(`unmocked: ${init?.method || 'GET'} ${url}`); }); vi.stubGlobal('fetch', fetchMock); diff --git a/hugo/data/whats_new.json b/hugo/data/whats_new.json index 03d0b7ad3..f7c727a5e 100644 --- a/hugo/data/whats_new.json +++ b/hugo/data/whats_new.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-08-27T17:55:22.401Z", + "generatedAt": "2026-08-28T20:07:37.161Z", "repos": [ { "key": "tutorials-ims", @@ -8,6 +8,240 @@ } ], "entries": [ + { + "id": "tutorials-ims#2076", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2076, + "title": "docs: register akamai-well-known-forward in sidebar (fix docs:build)", + "summary": "Corrected the developer-docs sidebar so a new operations runbook page publishes correctly.", + "category": "Docs", + "mergedAt": "2026-08-28T17:58:23Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2076" + }, + { + "id": "tutorials-ims#2075", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2075, + "title": "feat(approuter): complete .well-known MCP discovery surface (openid-config, mcp.json, WWW-Authenticate pointer)", + "summary": "External MCP clients such as the Claude and ChatGPT connectors can now auto-discover the platform's OAuth endpoints through a complete set of .well-known discovery documents.", + "category": "Feature", + "mergedAt": "2026-08-28T17:47:32Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2075" + }, + { + "id": "tutorials-ims#2074", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2074, + "title": "fix(freshness): calibrate prompt by severity so Check Freshness surfaces findings again", + "summary": "The admin Check Freshness tool once again surfaces potentially outdated API references instead of returning an empty report.", + "category": "Fix", + "mergedAt": "2026-08-28T16:15:34Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2074" + }, + { + "id": "tutorials-ims#2073", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2073, + "title": "fix(feature-flags): don't connect to db before model is loaded (fixes #2066 unit regression + latent boot bug)", + "summary": "Fixed a startup issue in the feature-flag system that could cause service errors before the data model finished loading.", + "category": "Fix", + "mergedAt": "2026-08-28T16:00:02Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2073" + }, + { + "id": "tutorials-ims#2072", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2072, + "title": "fix(me): AllCompletions/RecentActivity sign-in gate must check /auth/user JSON, not resp.ok", + "summary": "My Completions and Recent Activity now prompt you to sign in when your session has expired, instead of silently failing to load.", + "category": "Fix", + "mergedAt": "2026-08-28T14:41:55Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2072" + }, + { + "id": "tutorials-ims#2071", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2071, + "title": "fix(tokens): /me/tokens sign-in gate must check /auth/user JSON, not resp.ok", + "summary": "The personal access tokens page now shows a sign-in prompt when your session lapses, rather than a generic load error.", + "category": "Fix", + "mergedAt": "2026-08-28T14:34:04Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2071" + }, + { + "id": "tutorials-ims#2070", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2070, + "title": "fix(test): repair unit-suite fallout from flag→DB migration (WIP)", + "summary": "Internal test-suite repairs following the feature-flag configuration migration.", + "category": "Maintenance", + "mergedAt": "2026-08-28T13:47:42Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2070" + }, + { + "id": "tutorials-ims#2069", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2069, + "title": "fix(qa): slug-scope QA content publish + resilient commit + lock release", + "summary": "Made QA content republishing more reliable by scoping it to changed tutorials and hardening the publish step.", + "category": "Fix", + "mergedAt": "2026-08-28T13:47:08Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2069" + }, + { + "id": "tutorials-ims#2068", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2068, + "title": "fix(content): skip Option-B delta flag boot-seed under vitest", + "summary": "Test-environment fix so content fast-path defaults do not leak into the automated test suite.", + "category": "Maintenance", + "mergedAt": "2026-08-28T12:46:37Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2068" + }, + { + "id": "tutorials-ims#2066", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2066, + "title": "feat(feature-flags): migrate remaining 14 env flags to DB config (#2060)", + "summary": "Moved the remaining runtime feature flags to database-backed configuration so they are editable in admin and survive deployments.", + "category": "Maintenance", + "mergedAt": "2026-08-28T12:06:08Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2066" + }, + { + "id": "tutorials-ims#2064", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2064, + "title": "feat(content): default Option-B fast-path flags ON (seed ImsConfig if absent)", + "summary": "Content fast-path optimizations now default on and persist across deployments, keeping tutorial content updates fast.", + "category": "Performance", + "mergedAt": "2026-08-28T10:38:23Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2064" + }, + { + "id": "tutorials-ims#2062", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2062, + "title": "ci(rebuild-qa): add force-publish input to re-seed Option B ContentCurrent (QA)", + "summary": "Added a force-republish option to the QA content rebuild workflow.", + "category": "Maintenance", + "mergedAt": "2026-08-28T10:28:07Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2062" + }, + { + "id": "tutorials-ims#2061", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2061, + "title": "fix(content): move Option-B fast-path flags from env vars to DB config (ImsConfig)", + "summary": "Moved content fast-path settings to durable database configuration so they are no longer reset on deployment.", + "category": "Maintenance", + "mergedAt": "2026-08-28T10:27:42Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2061" + }, + { + "id": "tutorials-ims#2058", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2058, + "title": "Release 1.20.0 → PROD", + "summary": "Promoted the 1.20.0 release to production.", + "category": "Maintenance", + "mergedAt": "2026-08-27T20:41:46Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2058" + }, + { + "id": "tutorials-ims#2059", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2059, + "title": "chore: sync main into DEV (reconcile for 1.20.0 release)", + "summary": "Release housekeeping to keep the DEV and main branches in sync ahead of a release.", + "category": "Maintenance", + "mergedAt": "2026-08-27T20:41:22Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2059" + }, + { + "id": "tutorials-ims#2057", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2057, + "title": "perf(qa): reuse cached non-target content on QA slug-targeted rebuilds despite feed drift", + "summary": "QA single-tutorial content rebuilds are much faster now that untouched tutorials reuse cached content.", + "category": "Performance", + "mergedAt": "2026-08-27T19:30:20Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2057" + }, + { + "id": "tutorials-ims#2056", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2056, + "title": "fix(parsers): comment-aware section/intro extraction", + "summary": "Tutorials that disable a section with an HTML comment now render all their steps correctly, with no stray characters left on the page.", + "category": "Fix", + "mergedAt": "2026-08-27T19:12:29Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2056" + }, + { + "id": "tutorials-ims#2055", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2055, + "title": "chore(release): bump MTA 1.19.0 → 1.20.0 + refresh What's New digest", + "summary": "Release version bump and What's New digest refresh.", + "category": "Maintenance", + "mergedAt": "2026-08-27T18:10:53Z", + "week": "2026-W35", + "weekStart": "2026-08-24", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2055" + }, { "id": "tutorials-ims#2054", "repo": "tutorials-ims", diff --git a/package.json b/package.json index 85e27981f..22221db2d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev:hybrid": "npm run watch:hybrid & npm run start:approuter", "start:approuter": "cd approuter && NODE_ENV=development npm start", "bind:setup": "node scripts/setup-hybrid-env.js", - "seed:thirdparty": "cds bind --exec -- node scripts/seed-thirdparty.js", + "seed:thirdparty": "echo 'RETIRED: HomepageShelves (incl. third-party links) is now seeded idempotently at boot by srv/lib/homepage/seed-homepage-shelves.js from srv/lib/homepage/homepage-shelves-defaults.js. Edit that module or use /admin-ui/#homepage; no manual seed step needed.' && exit 1", "setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && sh scripts/install-git-hooks.sh", "build:cds": "cds build --production", "fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo", diff --git a/scripts/__tests__/publish-client.test.ts b/scripts/__tests__/publish-client.test.ts index d44dd65aa..7ad180922 100644 --- a/scripts/__tests__/publish-client.test.ts +++ b/scripts/__tests__/publish-client.test.ts @@ -81,6 +81,17 @@ describe('publish-client', () => { expect(out).toEqual({ slug1: 'h1', slug2: 'h2' }); }); + it('fetchRemoteHashes sends a Bearer header when apiKey is given (srv-qa gates /content/hashes)', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, status: 200, json: () => Promise.resolve({ slug1: 'h1' }), + }); + vi.stubGlobal('fetch', fetchMock); + await fetchRemoteHashes({ baseUrl, apiKey: 'qa-key' }); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe(`${baseUrl}/content/hashes`); + expect(opts?.headers).toEqual(expect.objectContaining({ Authorization: 'Bearer qa-key' })); + }); + it('renderConceptsPhase POSTs sessionId to /render-concepts and returns counts (#1327)', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, diff --git a/scripts/__tests__/publish-content-qa.test.ts b/scripts/__tests__/publish-content-qa.test.ts index 05e6f9395..075bf440a 100644 --- a/scripts/__tests__/publish-content-qa.test.ts +++ b/scripts/__tests__/publish-content-qa.test.ts @@ -20,11 +20,33 @@ describe('publish-content qa channel', () => { it('uses CAP_QA_BASE_URL and CONTENT_API_KEY_QA when channel=qa', () => { process.env.CAP_QA_BASE_URL = 'https://qa.example'; process.env.CONTENT_API_KEY_QA = 'qa-key'; - const cfg = resolvePublishConfig({ channel: 'qa' }); - expect(cfg.baseUrl).toBe('https://qa.example'); - expect(cfg.apiKey).toBe('qa-key'); - expect(cfg.sourceDir).toMatch(/public-qa$/); - expect(cfg.force).toBe(true); + const originalArgv = process.argv; + process.argv = process.argv.filter(a => a !== '--force'); + try { + const cfg = resolvePublishConfig({ channel: 'qa' }); + expect(cfg.baseUrl).toBe('https://qa.example'); + expect(cfg.apiKey).toBe('qa-key'); + expect(cfg.sourceDir).toMatch(/public-qa$/); + // QA is delta-by-default now (mirrors prod). A commit-triggered rebuild sets + // PUBLISH_SLUG for an O(changed) publish; force is opt-in via --force + // (workflow force-publish input) for a full re-seed. + expect(cfg.force).toBe(false); + } finally { + process.argv = originalArgv; + } + }); + + it('force-publishes on channel=qa only when --force is passed', () => { + process.env.CAP_QA_BASE_URL = 'https://qa.example'; + process.env.CONTENT_API_KEY_QA = 'qa-key'; + const originalArgv = process.argv; + process.argv = [...process.argv.filter(a => a !== '--force'), '--force']; + try { + const cfg = resolvePublishConfig({ channel: 'qa' }); + expect(cfg.force).toBe(true); + } finally { + process.argv = originalArgv; + } }); it('uses CAP_BASE_URL and CONTENT_API_KEY when channel=prod', () => { @@ -60,4 +82,20 @@ describe('publish-content qa channel', () => { 'See scripts/check-srv-qa-route-drift.ts ALLOWLIST_ONLY_ON_SRV.', ).not.toBeNull(); }); + + // Regression guard for the half-done #2062 fix: the QA publish step MUST forward + // the dispatched slug as PUBLISH_SLUG so a commit-triggered rebuild is slug-scoped + // (sub-second commit) instead of a ~35s full-catalog force write that intermittently + // 502/503s the single srv-qa instance. Without this, resolvePublishConfig's delta + // default is inert on QA (the workflow never narrows the publish). + it('QA workflow forwards the dispatched slug to the publish step as PUBLISH_SLUG', () => { + const wf = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.github', 'workflows', 'rebuild-content-qa.yml'), + 'utf8', + ); + // PUBLISH_SLUG must resolve from the dispatch payload or the workflow_dispatch input, + // and must be empty on a force-publish re-seed (force + slug is contradictory). + expect(wf).toMatch(/PUBLISH_SLUG:\s*\$\{\{[^}]*github\.event\.client_payload\.slug[^}]*inputs\.slug/); + expect(wf).toMatch(/PUBLISH_SLUG:\s*\$\{\{[^}]*inputs\.force-publish/); + }); }); diff --git a/scripts/__tests__/publish-retry.test.ts b/scripts/__tests__/publish-retry.test.ts index 0fc044d4f..4811d7011 100644 --- a/scripts/__tests__/publish-retry.test.ts +++ b/scripts/__tests__/publish-retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { withRetry, classifyError, formatErrorChain } from '../lib/publish-retry.js'; +import { withRetry, classifyError, formatErrorChain, computeBackoff } from '../lib/publish-retry.js'; describe('classifyError', () => { it('classifies HTTP 5xx as transient', () => { @@ -49,6 +49,26 @@ describe('formatErrorChain', () => { }); }); +describe('computeBackoff', () => { + it('returns baseMs unchanged when jitterRatio is 0 or omitted', () => { + expect(computeBackoff(2000)).toBe(2000); + expect(computeBackoff(2000, 0)).toBe(2000); + // rand should be ignored when there is no jitter + expect(computeBackoff(2000, 0, () => 0.99)).toBe(2000); + }); + + it('scales within [1-r, 1+r] of baseMs', () => { + // rand()=0 → factor 1-r (low end); rand()=1 → factor 1+r (high end); 0.5 → base + expect(computeBackoff(1000, 0.2, () => 0)).toBe(800); + expect(computeBackoff(1000, 0.2, () => 1)).toBe(1200); + expect(computeBackoff(1000, 0.2, () => 0.5)).toBe(1000); + }); + + it('never returns a negative wait', () => { + expect(computeBackoff(100, 5, () => 0)).toBe(0); + }); +}); + describe('withRetry', () => { beforeEach(() => { vi.useFakeTimers(); }); diff --git a/scripts/__tests__/seed-thirdparty-data.test.ts b/scripts/__tests__/seed-thirdparty-data.test.ts deleted file mode 100644 index d8530fa7a..000000000 --- a/scripts/__tests__/seed-thirdparty-data.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { KNOWN_TAGS } from '../../srv/lib/homepage/persona-tag-validator.js'; - -const KNOWN = new Set(KNOWN_TAGS); -const VERBS = new Set(['LEARN', 'BUILD', 'INTEGRATE', 'MODEL', 'OPERATE', 'AI', 'CONNECT']); -const SHELVES = new Set(['START_HERE', 'REFERENCE', 'TOOLS', 'KEEP_CURRENT']); - -const jsonUrl = new URL('../../db/data/staging/homepage-thirdparty.json', import.meta.url); -// Seed CSV moved db/data/ → test/data/ in #1673 (stops deploy wiping admin-editable columns). -const csvUrl = new URL('../../test/data/com.sap.developers.ims-HomepageShelves.csv', import.meta.url); - -const raw = JSON.parse(readFileSync(fileURLToPath(jsonUrl), 'utf-8')); -const rows = raw.filter((r) => !r._comment); - -const csvText = readFileSync(fileURLToPath(csvUrl), 'utf-8'); -const csvLines = csvText.split(/\r?\n/).filter((l) => l.trim()); -const csvHeader = csvLines[0].split(';'); -const idIdx = csvHeader.indexOf('ID'); -const verbIdx = csvHeader.indexOf('verb'); -const urlIdx = csvHeader.indexOf('url'); -const csvIds = new Set(csvLines.slice(1).map((l) => l.split(';')[idIdx])); -const csvVerbUrls = new Set(csvLines.slice(1).map((l) => { - const c = l.split(';'); - return `${c[verbIdx]}|${c[urlIdx]}`; -})); - -describe('homepage-thirdparty staging data', () => { - it('parses to a non-empty array of content rows', () => { - expect(Array.isArray(raw)).toBe(true); - expect(rows.length).toBe(23); - }); - - it('every row has required fields with correct fixed values', () => { - const ALLOWED_BADGES = new Set([null, 'NEW', 'UPDATED', 'HIDDEN_GEM', 'THIRD_PARTY']); - for (const r of rows) { - expect(typeof r.ID).toBe('string'); - expect(VERBS.has(r.verb)).toBe(true); - expect(SHELVES.has(r.shelf)).toBe(true); - expect(Number.isInteger(r.sortOrder)).toBe(true); - expect(r.title.length).toBeGreaterThan(0); - expect(r.title.length).toBeLessThanOrEqual(120); - expect(r.description.length).toBeLessThanOrEqual(280); - expect(r.tagline.length).toBeLessThanOrEqual(140); - expect(r.whyItMatters.length).toBeLessThanOrEqual(800); - // THIRD_PARTY badge intentionally retired — it lost meaning at scale. - // Badge is now null for the ecosystem links; RPT-1 rows carry NEW. - expect(ALLOWED_BADGES.has(r.badge)).toBe(true); - expect(r.badge).not.toBe('THIRD_PARTY'); - expect(r.isExternal).toBe(true); - expect(r.isActive).toBe(true); - expect(r.authoringStatus).toBe('REVIEWED'); - expect(r.personaWeight).toBe(0); - } - }); - - it('every url is absolute https', () => { - for (const r of rows) { - expect(r.url.startsWith('https://')).toBe(true); - } - }); - - it('every persona tag is in KNOWN_TAGS', () => { - for (const r of rows) { - expect(Array.isArray(r.personaTags)).toBe(true); - for (const t of r.personaTags) { - expect(KNOWN.has(t)).toBe(true); - } - } - }); - - it('(verb,url) pairs are unique within the file', () => { - const seen = new Set(); - for (const r of rows) { - const key = `${r.verb}|${r.url}`; - expect(seen.has(key)).toBe(false); - seen.add(key); - } - }); - - it('does not collide with existing canonical CSV on ID or (verb,url)', () => { - for (const r of rows) { - expect(csvIds.has(r.ID)).toBe(false); - expect(csvVerbUrls.has(`${r.verb}|${r.url}`)).toBe(false); - } - }); -}); diff --git a/scripts/__tests__/seed-thirdparty-loader.test.ts b/scripts/__tests__/seed-thirdparty-loader.test.ts deleted file mode 100644 index 5e1976582..000000000 --- a/scripts/__tests__/seed-thirdparty-loader.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import cds from '@sap/cds'; -import { seedThirdParty } from '../seed-thirdparty.js'; - -let db; -let HomepageShelves; - -beforeAll(async () => { - const model = await cds.load('*'); - db = await cds.deploy(model).to('sqlite::memory:'); - ({ HomepageShelves } = cds.entities('com.sap.developers.ims')); -}, 60000); - -describe('seedThirdParty', () => { - it('first run inserts all rows', async () => { - const res = await seedThirdParty(db); - expect(res.inserted).toBe(23); - expect(res.updated).toBe(0); - const staged = await db.run(SELECT.from(HomepageShelves).columns('ID').where( - `ID like '66333900-3rd0-%' or ID like '66333900-rpt1-%'` - )); - expect(staged.length).toBe(23); - }); - - it('second run updates in place, inserts nothing (idempotent)', async () => { - const res = await seedThirdParty(db); - expect(res.inserted).toBe(0); - expect(res.updated).toBe(23); - const staged = await db.run(SELECT.from(HomepageShelves).columns('ID').where( - `ID like '66333900-3rd0-%' or ID like '66333900-rpt1-%'` - )); - expect(staged.length).toBe(23); - }); - - it('persists personaTags as an array and clears the THIRD_PARTY badge', async () => { - const vercel = await db.run(SELECT.one.from(HomepageShelves).where({ verb: 'BUILD', url: 'https://vercel.com' })); - expect(vercel).toBeTruthy(); - expect(vercel.personaTags).toContain('role:developer'); - expect(vercel.badge).toBeNull(); - }); - - it('marks the RPT-1 rows with the NEW badge', async () => { - const rpt = await db.run(SELECT.one.from(HomepageShelves).where({ verb: 'AI', url: 'https://rpt.cloud.sap' })); - expect(rpt).toBeTruthy(); - expect(rpt.title).toBe('SAP RPT-1 Playground'); - expect(rpt.badge).toBe('NEW'); - }); -}); diff --git a/scripts/lib/publish-client.ts b/scripts/lib/publish-client.ts index 6d5880610..121e60855 100644 --- a/scripts/lib/publish-client.ts +++ b/scripts/lib/publish-client.ts @@ -112,8 +112,11 @@ export async function abortSession({ baseUrl, apiKey, sessionId, reason }: { } } -export async function fetchRemoteHashes({ baseUrl }: { baseUrl: string }): Promise> { - const res = await fetch(`${baseUrl}/content/hashes`); +export async function fetchRemoteHashes({ baseUrl, apiKey }: { baseUrl: string; apiKey?: string }): Promise> { + // srv-qa gates /content/hashes behind contentAuthMiddleware (unlike prod srv, + // where it's public-read). Send the bearer when we have one — harmless on the + // public prod route, required on QA or delta detection 401s. + const res = await fetch(`${baseUrl}/content/hashes`, apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : undefined); if (!res.ok) { if (res.status === 503) return {}; const err: any = new Error(`HTTP ${res.status}`); @@ -130,10 +133,11 @@ export async function fetchRemoteHashes({ baseUrl }: { baseUrl: string }): Promi * Slugs whose sourceHash is null (e.g. published before PR #591) are omitted * from the response — drift check skips those by design. * - * Public-read like /content/hashes; no auth needed. + * Public-read on prod srv; srv-qa gates it behind contentAuthMiddleware, so pass + * apiKey when publishing to QA. */ -export async function fetchRemoteSourceHashes({ baseUrl }: { baseUrl: string }): Promise> { - const res = await fetch(`${baseUrl}/content/source-hashes`); +export async function fetchRemoteSourceHashes({ baseUrl, apiKey }: { baseUrl: string; apiKey?: string }): Promise> { + const res = await fetch(`${baseUrl}/content/source-hashes`, apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : undefined); if (!res.ok) { if (res.status === 503) return {}; if (res.status === 404) { diff --git a/scripts/lib/publish-retry.ts b/scripts/lib/publish-retry.ts index f1dc17b56..d8b583b11 100644 --- a/scripts/lib/publish-retry.ts +++ b/scripts/lib/publish-retry.ts @@ -41,6 +41,23 @@ export interface RetryOptions { attempts: number; backoffMs: number[]; onAttemptFail?: (attempt: number, err: any, willRetry: boolean) => void; + /** + * Optional +/- jitter as a fraction of the base delay (e.g. 0.2 = ±20%). + * Defaults to 0 (no jitter) so existing callers are byte-for-byte unchanged. + * Jitter de-synchronizes retries so all three DevRel projects' publishes don't + * re-hit a briefly-unhealthy backend in lockstep. + */ + jitterRatio?: number; +} + +/** + * Compute the wait before the next attempt. Pure + injectable rand for tests. + * With jitterRatio r, returns baseMs scaled by a factor in [1-r, 1+r], floored at 0. + */ +export function computeBackoff(baseMs: number, jitterRatio = 0, rand: () => number = Math.random): number { + if (!jitterRatio) return baseMs; + const factor = 1 + (rand() * 2 - 1) * jitterRatio; // [1-r, 1+r) + return Math.max(0, Math.round(baseMs * factor)); } export async function withRetry(fn: () => Promise, opts: RetryOptions): Promise { @@ -54,7 +71,8 @@ export async function withRetry(fn: () => Promise, opts: RetryOptions): Pr const willRetry = cls === 'transient' && attempt < opts.attempts; opts.onAttemptFail?.(attempt, err, willRetry); if (!willRetry) break; - const wait = opts.backoffMs[Math.min(attempt - 1, opts.backoffMs.length - 1)]; + const base = opts.backoffMs[Math.min(attempt - 1, opts.backoffMs.length - 1)]; + const wait = computeBackoff(base, opts.jitterRatio); await new Promise(r => setTimeout(r, wait)); } } diff --git a/scripts/publish-content.ts b/scripts/publish-content.ts index 326b37f63..9761957db 100644 --- a/scripts/publish-content.ts +++ b/scripts/publish-content.ts @@ -35,7 +35,13 @@ export function resolvePublishConfig({ channel }: { channel: Channel }): Publish baseUrl: process.env.CAP_QA_BASE_URL ?? 'http://localhost:4005', apiKey: process.env.CONTENT_API_KEY_QA, sourceDir: 'hugo/public-qa', - force: true, + // Delta by default (mirrors prod), so a commit-triggered QA rebuild that + // sets PUBLISH_SLUG publishes only the changed slug — a sub-second commit + // instead of a ~35s full-catalog force write that intermittently 502/503s + // the single srv-qa instance. `force-publish=true` (workflow input → --force) + // still does a full re-seed. Safe now that QA has Option B fully active + // (mutable ContentCurrent, O(changed) publish, server carries unchanged slugs). + force: process.argv.includes('--force'), }; } return { @@ -754,7 +760,7 @@ async function main() { // 2) Fetch server's source-hash map. let remote: Record; try { - remote = await fetchRemoteSourceHashes({ baseUrl: opts.baseUrl }); + remote = await fetchRemoteSourceHashes({ baseUrl: opts.baseUrl, apiKey: opts.apiKey }); } catch (err) { console.error('Verify failed: cannot reach /content/source-hashes:', formatErrorChain(err)); process.exit(1); @@ -845,7 +851,7 @@ async function main() { // 3. Fetch /content/source-hashes let remote: Record; - try { remote = await fetchRemoteSourceHashes({ baseUrl: opts.baseUrl }); } + try { remote = await fetchRemoteSourceHashes({ baseUrl: opts.baseUrl, apiKey: opts.apiKey }); } catch (err) { console.error('purge-orphans: cannot reach /content/source-hashes:', formatErrorChain(err)); process.exit(1); @@ -1074,7 +1080,7 @@ async function main() { let remoteHashes: Record = {}; if (mode !== 'force') { log(`Fetching remote hashes from ${opts.baseUrl}/content/hashes...`); - try { remoteHashes = await fetchRemoteHashes({ baseUrl: opts.baseUrl }); } + try { remoteHashes = await fetchRemoteHashes({ baseUrl: opts.baseUrl, apiKey: opts.apiKey }); } catch (err) { console.error(`Cannot reach ${opts.baseUrl}/content/hashes: ${formatErrorChain(err)}`); process.exit(1); @@ -1101,7 +1107,7 @@ async function main() { const localSourceHashes = computeLocalSourceHashes(targetSlugs, cacheDirForHashes); let serverSourceHashes: Record = {}; try { - serverSourceHashes = await fetchRemoteSourceHashes({ baseUrl: opts.baseUrl }); + serverSourceHashes = await fetchRemoteSourceHashes({ baseUrl: opts.baseUrl, apiKey: opts.apiKey }); } catch (err) { console.warn(`[publish-content] #672 short-circuit disengaged: cannot reach /content/source-hashes: ${formatErrorChain(err)}`); } @@ -1244,14 +1250,34 @@ async function main() { commit = await withRetry( () => commitSession({ baseUrl: opts.baseUrl, apiKey: opts.apiKey, sessionId: begin.sessionId, allowRevertSlugs }), { - attempts: 3, backoffMs: [1000, 3000, 9000], + // Widened from [1000,3000,9000]/3 attempts: the old schedule only ever + // waited 1s then 3s (~4s total window) — too short to ride out a transient + // gorouter 502/503 blip on the single srv-qa instance (the commit is the + // longest single request in the publish). ±20% jitter de-syncs retries + // across the three DevRel projects. 502/503/504 are already classified + // transient in publish-retry.ts. + attempts: 5, backoffMs: [2000, 5000, 10000, 20000], jitterRatio: 0.2, onAttemptFail: (attempt, err, willRetry) => { - console.error(`[publish-content] commit failed (attempt ${attempt}/3): ${formatErrorChain(err)}${willRetry ? ' — retrying' : ''}`); + console.error(`[publish-content] commit failed (attempt ${attempt}/5): ${formatErrorChain(err)}${willRetry ? ' — retrying' : ''}`); }, } ); } catch (err) { - console.error(`[publish-content] commit failed permanently — manifest left for GC reaper: ${formatErrorChain(err)}`); + console.error(`[publish-content] commit failed permanently: ${formatErrorChain(err)}`); + // Release the server-side publish lock + mark the manifest FAILED instead of + // stranding it for the 30-min TTL (which 409s every QA rebuild in that window). + // Mirrors the append-failure path; abort gets its own transient retry since the + // 502/503 that killed the commit is usually a brief blip that has cleared by now. + // If abort still can't reach the server, the stuck-manifest reaper is the backstop. + try { + await withRetry( + () => abortSession({ baseUrl: opts.baseUrl, apiKey: opts.apiKey, sessionId: begin.sessionId, reason: 'commit failed' }), + { attempts: 4, backoffMs: [1000, 3000, 6000], jitterRatio: 0.2 } + ); + console.error('[publish-content] session aborted — publish lock released'); + } catch (abortErr) { + console.error(`[publish-content] abort after commit failure also failed — manifest left for GC reaper: ${formatErrorChain(abortErr)}`); + } process.exit(1); } @@ -1328,7 +1354,7 @@ async function main() { // --- auto-verify --- log('Verifying server state matches local...'); let postRemote: Record; - try { postRemote = await fetchRemoteHashes({ baseUrl: opts.baseUrl }); } + try { postRemote = await fetchRemoteHashes({ baseUrl: opts.baseUrl, apiKey: opts.apiKey }); } catch (err) { console.error(`Auto-verify warning: cannot reach /content/hashes after commit: ${formatErrorChain(err)}`); process.exit(0); // commit was successful; don't punish for a transient verify-fetch error diff --git a/scripts/seed-thirdparty.js b/scripts/seed-thirdparty.js deleted file mode 100644 index d5e8ef6d9..000000000 --- a/scripts/seed-thirdparty.js +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -// Idempotent loader for homepage third-party content. -// Upserts db/data/staging/homepage-thirdparty.json into HomepageShelves, -// keyed on (verb, url). Run against DEV: `npm run seed:thirdparty`. -// PROD promotion is manual — see the file's _comment and the spec -// (docs/superpowers/specs/2026-07-30-homepage-thirdparty-content-design.md). -import cds from '@sap/cds'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { KNOWN_TAGS } from '../srv/lib/homepage/persona-tag-validator.js'; - -const KNOWN = new Set(KNOWN_TAGS); -const DATA_URL = new URL('../db/data/staging/homepage-thirdparty.json', import.meta.url); - -function loadRows() { - const raw = JSON.parse(readFileSync(fileURLToPath(DATA_URL), 'utf-8')); - return raw.filter((r) => !r._comment); -} - -function validate(rows) { - const errors = []; - const seen = new Set(); - for (const r of rows) { - if (!r.verb || !r.url) errors.push(`row ${r.ID || '?'}: missing verb/url`); - if (typeof r.url !== 'string' || !r.url.startsWith('https://')) { - errors.push(`row ${r.ID}: url must be absolute https`); - } - const key = `${r.verb}|${r.url}`; - if (seen.has(key)) errors.push(`duplicate (verb,url): ${key}`); - seen.add(key); - for (const t of r.personaTags || []) { - if (!KNOWN.has(t)) errors.push(`row ${r.ID}: unknown personaTag "${t}"`); - } - } - if (errors.length) { - throw new Error(`seed-thirdparty validation failed:\n ${errors.join('\n ')}`); - } -} - -const NAMESPACE = 'com.sap.developers.ims'; - -export async function seedThirdParty(dbOverride) { - const db = dbOverride ?? await cds.connect.to('db'); - // Reflect the entity via cds.linked so CQL is type-aware and serializes the - // `personaTags` array to JSON for the HANA NCLOB column. `cds.entities` is a - // getter that is undefined in a standalone script (only set after cds.serve), - // and a fully-qualified string name is NOT type-aware (fails on HANA with - // "Wrong input for LOB type"). cds.linked(csn) works in every context. - const linked = cds.linked(cds.model ?? await cds.load('*')); - const { HomepageShelves } = linked.entities(NAMESPACE); - const rows = loadRows(); - validate(rows); - - let inserted = 0; - let updated = 0; - for (const r of rows) { - const existing = await db.run(SELECT.one.from(HomepageShelves).columns('ID').where({ verb: r.verb, url: r.url })); - if (existing) { - const { ID, ...patch } = r; // keep existing ID on update - await db.run(UPDATE(HomepageShelves).set(patch).where({ ID: existing.ID })); - updated++; - } else { - await db.run(INSERT.into(HomepageShelves).entries(r)); - inserted++; - } - } - return { inserted, updated }; -} - -// CLI entry -if (fileURLToPath(import.meta.url) === process.argv[1]) { - seedThirdParty() - .then(({ inserted, updated }) => { - console.log(`seed-thirdparty: ${inserted} inserted, ${updated} updated`); - process.exit(0); - }) - .catch((err) => { - console.error(err.message || err); - process.exit(1); - }); -} diff --git a/srv/admin-service.cds b/srv/admin-service.cds index f38f1f8a5..3a872801d 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -383,6 +383,10 @@ service AdminService { // Feature Flag Viewer (#feature-flags). Unbacked read-only entity; rows are // synthesized in srv/admin-service.js on('READ') from // srv/lib/feature-flags/resolve.js. No DB table. + // Bound row actions enable()/disable() flip the backing ImsConfig row for a + // kind:'db' flag (generic flag.* via db-flags.js, or a content.delta.* flag via + // content-delta-flags.js) — rendered as List Report / Object Page buttons + // (#2060). Non-db flags reject with 400. @readonly @cds.persistence.skip @Capabilities: { InsertRestrictions: { Insertable: false }, UpdateRestrictions: { Updatable: false }, DeleteRestrictions: { Deletable: false } } @@ -402,6 +406,9 @@ service AdminService { rawEnvValue : String(120); defaultValue : String(60); howToChangeText : String(500); + } actions { + action enable() returns FeatureFlags; + action disable() returns FeatureFlags; } @odata.singleton @@ -777,6 +784,23 @@ service AdminService { // (legacy IMS already credited them). Empty/null clears it. See ngds-autosend.js. action setNgdsAutoSendEpoch(epoch : String) returns { epoch : String }; function getNgdsAutoSendConfig() returns { enabled : Boolean; environment : String; epoch : String; effective : Boolean }; + // Content Option-B delta flags (ImsConfig-backed). Moved from env vars to + // DB config so an admin can flip the ContentFiles→ContentCurrent migration + // gates without a redeploy. Omitted fields are left unchanged. Setter busts + // the 60s server-side cache so a flip is effective at once. + action setContentDeltaFlags(write : Boolean, read : Boolean, skipCarryForward : Boolean) + returns { write : Boolean; read : Boolean; skipCarryForward : Boolean }; + function getContentDeltaFlags() + returns { write : Boolean; read : Boolean; skipCarryForward : Boolean }; + // Generic ImsConfig-backed on/off feature flags (issue #2060). Moved from env + // vars to DB config so an admin can flip them without a redeploy. `key` is the + // registry key (e.g. 'METRICS_ENABLED', 'KG_PAGERANK_ENABLED'). The setter + // upserts the backing ImsConfig row and busts the 60s server-side cache so a + // flip is effective at once. + action setFeatureFlag(flag : String, enabled : Boolean) + returns { flag : String; enabled : Boolean }; + function getFeatureFlags() + returns many { flag : String; imsConfigKey : String; enabled : Boolean; default : Boolean }; action testNotificationEmail(to: String, level: Integer) returns { success : Boolean; error : String; diff --git a/srv/admin-service.js b/srv/admin-service.js index 92e678d64..8e5610965 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -26,6 +26,10 @@ import { cleanupChangeLog, cleanupUnusedTags } from './jobs/cleanup.js'; import { ensureDevtoberfestActiveFlagInvariant } from './lib/devtoberfest-active-flag.js'; import { enrichSignupRows } from './lib/devtoberfest-signup-enrich.js'; import { getTutorialSource } from './lib/content-store.js'; +import { isDeltaRead, bustContentDeltaFlagsCache } from './lib/content-delta-flags.js'; +import { + bustFeatureFlagsCache, managedFlagKeys, flagMeta, +} from './lib/feature-flags/db-flags.js'; import { buildTutorialLinks } from './lib/tutorial-links.js'; import { buildPreviewLinks } from './lib/preview-links.js'; import { runSeedApiDocs } from './lib/seed-api-docs.js'; @@ -55,6 +59,7 @@ import { computeKgCommunityFingerprint } from './lib/kg-community-fingerprint.js import * as mcpAdmin from './lib/mcp-admin-tools.js'; // #1106 Phase 3 (WS2) admin MCP tools import { computeCoverage, resolveThreshold } from './lib/kg-community-coverage.js'; // #1172 import { resolveFeatureFlags } from './lib/feature-flags/resolve.js'; // #feature-flags +import { FEATURE_FLAGS } from './lib/feature-flags/registry.js'; // #2060 bound enable/disable import { resetFeaturedCache } from './lib/featured-resolve.js'; // #756: max jobName payload length. Matches JobLocks.jobName : String(100) @@ -516,6 +521,42 @@ export default class AdminService extends cds.ApplicationService { return rows; }); + // Bound row actions enable()/disable() on FeatureFlags (#2060). FeatureFlags + // is a synthesized @cds.persistence.skip viewer, so these flip the backing + // ImsConfig row for a kind:'db' flag and return the freshly re-resolved row + // so FE refreshes it. Two actions (not one param'd toggle) render as clean + // row buttons, mirroring NewsItems/approve|reject in content-moderation. + const setFeatureFlagRow = async (req, enabled) => { + // Bound-action key: CAP surfaces the entity key on req.params[0].key. + const params = req.params; + const last = Array.isArray(params) && params.length ? params[params.length - 1] : null; + const key = last && typeof last === 'object' ? last.key : last; + const desc = FEATURE_FLAGS.find((f) => f.key === key); + if (!desc || desc.kind !== 'db' || !desc.imsConfigKey) { + return req.reject(400, 'Only DB feature flags are togglable here'); + } + const { ImsConfig } = cds.entities('com.sap.developers.ims'); + const value = String(Boolean(enabled)); + const existing = await SELECT.one.from(ImsConfig).where({ key: desc.imsConfigKey }); + if (existing) { + await UPDATE(ImsConfig, existing.ID).set({ value }); + } else { + await INSERT.into(ImsConfig).entries({ ID: cds.utils.uuid(), key: desc.imsConfigKey, value }); + } + // Dispatch the cache bust to the owning module: the content.delta.* flags + // keep their dedicated content-delta-flags.js cache (flagMeta returns null + // for them since db-flags.js excludes them); everything else is generic. + if (flagMeta(key)) bustFeatureFlagsCache(); + else bustContentDeltaFlagsCache(); + // Re-resolve (resolve.js reads ImsConfig live, not the cache) and return + // the updated row so the FE list/object page reflects the new state. + const all = await resolveFeatureFlags(); + return all.find((r) => r.key === key) ?? null; + }; + + this.on('enable', 'FeatureFlags', (req) => setFeatureFlagRow(req, true)); + this.on('disable', 'FeatureFlags', (req) => setFeatureFlagRow(req, false)); + // Virtual severityCrit element (drives @UI.LineItem Criticality coloring). // Information=3 (Neutral), Success=5 (Positive), Warning=2 (Critical), Error=1 (Negative) this.after('READ', 'Alerts', rows => { @@ -1640,7 +1681,7 @@ export default class AdminService extends cds.ApplicationService { // Option B: active slug set from ContentCurrent when the read flag is on. const ContentCurrent = cds.entities('com.sap.developers.ims').ContentCurrent; - const files = (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) + const files = (isDeltaRead() && ContentCurrent) ? await SELECT.from(ContentCurrent).columns('slug') : await SELECT.from(ContentFiles).columns('slug').where({ version: manifest.version }); const slugs = files.map(f => f.slug); @@ -1946,6 +1987,97 @@ export default class AdminService extends cds.ApplicationService { }; }); + // ---- Content Option-B delta flags (ImsConfig-backed, DB-driven config) ---- + // Three flags gate the migration off the legacy ContentFiles snapshot model + // onto the mutable ContentCurrent model. Moved from env vars to ImsConfig so + // an admin can flip them without a redeploy. Cache lives in + // srv/lib/content-delta-flags.js (60s TTL); we bust it on write so a toggle + // takes effect immediately. Values stored as 'true'/'false' strings. + this.on('setContentDeltaFlags', async (req) => { + const { ImsConfig } = cds.entities('com.sap.developers.ims'); + const { DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY } = + await import('./lib/content-delta-flags.js'); + const { write, read, skipCarryForward } = req.data; + // Only upsert the flags actually supplied (undefined → leave unchanged), + // so a caller can flip one flag without clobbering the other two. + const upserts = [ + [DELTA_WRITE_KEY, write], + [DELTA_READ_KEY, read], + [DELTA_SKIP_CARRYFORWARD_KEY, skipCarryForward], + ]; + for (const [key, val] of upserts) { + if (val === undefined || val === null) continue; + const value = String(Boolean(val)); + const existing = await SELECT.one.from(ImsConfig).where({ key }); + if (existing) { + await UPDATE(ImsConfig, existing.ID).set({ value }); + } else { + await INSERT.into(ImsConfig).entries({ key, value }); + } + } + // Flush the 60s cache so the flip is effective at once. + bustContentDeltaFlagsCache(); + return getContentDeltaFlags(); + }); + + this.on('getContentDeltaFlags', async () => getContentDeltaFlags()); + + async function getContentDeltaFlags() { + const { ImsConfig } = cds.entities('com.sap.developers.ims'); + const { DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY } = + await import('./lib/content-delta-flags.js'); + const readKey = async (key) => { + const row = await SELECT.one.from(ImsConfig).where({ key }); + return String(row?.value).toLowerCase() === 'true'; + }; + return { + write: await readKey(DELTA_WRITE_KEY), + read: await readKey(DELTA_READ_KEY), + skipCarryForward: await readKey(DELTA_SKIP_CARRYFORWARD_KEY), + }; + } + + // ---- Generic ImsConfig-backed feature flags (issue #2060) ---- + // The 14 formerly-env on/off flags (metrics, MCP, KG jobs, homepage news + // relevance, freshness scan, etc.) are now ImsConfig rows read live through + // srv/lib/feature-flags/db-flags.js (60s cache). setFeatureFlag upserts one + // flag by its registry key and busts the cache so the flip is immediate. + this.on('setFeatureFlag', async (req) => { + const { flag: key, enabled } = req.data; + const meta = flagMeta(key); + if (!meta) return req.reject(400, `Unknown feature flag key: ${key}`); + const { ImsConfig } = cds.entities('com.sap.developers.ims'); + const value = String(Boolean(enabled)); + const existing = await SELECT.one.from(ImsConfig).where({ key: meta.imsConfigKey }); + if (existing) { + await UPDATE(ImsConfig, existing.ID).set({ value }); + } else { + await INSERT.into(ImsConfig).entries({ ID: cds.utils.uuid(), key: meta.imsConfigKey, value }); + } + bustFeatureFlagsCache(); + return { flag: key, enabled: Boolean(enabled) }; + }); + + this.on('getFeatureFlags', async () => { + // Read the backing ImsConfig rows directly (not the 60s cache) so the + // result reflects the true persisted value — including a value just set + // via setFeatureFlag, whose bust leaves the cache cold. + const { ImsConfig } = cds.entities('com.sap.developers.ims'); + const keys = managedFlagKeys(); + const metas = keys.map((key) => ({ key, ...flagMeta(key) })); + const rows = await SELECT.from(ImsConfig) + .columns('key', 'value') + .where({ key: { in: metas.map((m) => m.imsConfigKey) } }); + const byKey = Object.create(null); + for (const r of rows || []) byKey[r.key] = String(r.value).toLowerCase() === 'true'; + return metas.map((m) => ({ + flag: m.key, + imsConfigKey: m.imsConfigKey, + enabled: m.imsConfigKey in byKey ? byKey[m.imsConfigKey] : m.default, + default: m.default, + })); + }); + this.on('findMissingSlugs', async () => { const { findMissingSlugs } = await import('./lib/slug-mapping.js'); return findMissingSlugs(); diff --git a/srv/homepage-service.js b/srv/homepage-service.js index 6c7f07e6b..8f7242906 100644 --- a/srv/homepage-service.js +++ b/srv/homepage-service.js @@ -25,6 +25,7 @@ import * as mcpHp from './lib/mcp-homepage-tools.js'; import { buildEnvelope, hashEnvelope } from './lib/homepage/personalized-envelope.js'; import { resolveUserSapId } from './lib/resolve-db-user.js'; import * as metrics from './lib/metrics.js'; +import { isFlagEnabled } from './lib/feature-flags/db-flags.js'; import { readSnapshotForFeed } from './lib/featured-topics-snapshot.js'; import { readSnapshotForFeed as readTtSnapshotForFeed } from './lib/top-tutorials-snapshot.js'; import { buildTopicClustersPayload } from './lib/build-topic-clusters.js'; @@ -102,7 +103,7 @@ const NEWS_CACHE_MS = 60_000; const NEWS_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; async function _isNewsRelevanceEnabled() { - if (process.env.HOMEPAGE_NEWS_RELEVANCE_ENABLED === 'false') return false; + if (!isFlagEnabled('HOMEPAGE_NEWS_RELEVANCE_ENABLED')) return false; try { const db = await cds.connect.to('db'); const { HomepageConfig } = cds.entities('com.sap.developers.ims'); diff --git a/srv/jobs/cleanup.js b/srv/jobs/cleanup.js index a901699ac..dff321d7c 100644 --- a/srv/jobs/cleanup.js +++ b/srv/jobs/cleanup.js @@ -1,5 +1,6 @@ import cds from '@sap/cds'; import * as alerting from '../lib/alerting.js'; +import { isDeltaRead } from '../lib/content-delta-flags.js'; /** * HANA prepared-statement parameter buffer is bounded by `communication_max_packet_size` @@ -229,7 +230,7 @@ export async function pruneOrphanEmbeddings() { // ContentCurrent must be fully seeded), else the legacy manifest snapshot. // Prunes embeddings for slugs NOT in this set, so an incomplete source would // over-prune — the read flag is only enabled once ContentCurrent is seeded. - const files = (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) + const files = (isDeltaRead() && ContentCurrent) ? await SELECT.from(ContentCurrent).columns('slug') : await SELECT.from(ContentFiles).columns('slug').where({ version: manifest.version }); const activeSlugs = new Set(files.map(f => f.slug)); diff --git a/srv/jobs/embedding-reconciliation.js b/srv/jobs/embedding-reconciliation.js index 3792d679c..52e9d24b4 100644 --- a/srv/jobs/embedding-reconciliation.js +++ b/srv/jobs/embedding-reconciliation.js @@ -1,6 +1,7 @@ import cds from '@sap/cds'; import { embedSlugs } from '../lib/embedding-pipeline.js'; import { logJobItem } from '../lib/pipeline-log.js'; +import { isDeltaRead } from '../lib/content-delta-flags.js'; const LOG = cds.log('rag-reconcile'); @@ -39,7 +40,7 @@ export async function runReconciliationJob(logId) { return { skipped: true, reason: 'no active manifest' }; } - const activeFiles = (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) + const activeFiles = (isDeltaRead() && ContentCurrent) ? await SELECT.from(ContentCurrent).columns('slug') : await SELECT.from(ContentFiles).columns('slug').where({ version: manifest.version }); const activeSlugs = activeFiles.map((f) => f.slug); diff --git a/srv/jobs/freshness-scan-job.js b/srv/jobs/freshness-scan-job.js index cdc914606..c72d3307d 100644 --- a/srv/jobs/freshness-scan-job.js +++ b/srv/jobs/freshness-scan-job.js @@ -14,6 +14,7 @@ import cds from '@sap/cds'; import { detectFreshness } from '../lib/freshness-detector.js'; import { persistReport } from '../lib/freshness-persist.js'; +import { isFlagEnabled } from '../lib/feature-flags/db-flags.js'; const LOG = cds.log('freshness-scan'); const DEFAULT_LIMIT = 50; // budget cap per run @@ -28,8 +29,8 @@ const DEFAULT_LIMIT = 50; // budget cap per run * @returns {Promise<{ scanned: number, skipped: boolean }>} */ export async function runFreshnessScan(_logId, opts = {}) { - if (process.env.FRESHNESS_SCAN_ENABLED !== 'true') { - LOG.info('FRESHNESS_SCAN_ENABLED != true — skipping'); + if (!isFlagEnabled('FRESHNESS_SCAN_ENABLED')) { + LOG.info('FRESHNESS_SCAN_ENABLED flag off — skipping'); return { scanned: 0, skipped: true }; } diff --git a/srv/jobs/kg-retire-orphans-job.js b/srv/jobs/kg-retire-orphans-job.js index 5e2607eaf..3ca43d0fd 100644 --- a/srv/jobs/kg-retire-orphans-job.js +++ b/srv/jobs/kg-retire-orphans-job.js @@ -37,6 +37,7 @@ import cds from '@sap/cds'; import * as metrics from '../lib/metrics.js'; +import { isFlagEnabled } from '../lib/feature-flags/db-flags.js'; const NS = 'com.sap.developers.ims'; const LOG = cds.log('kg-retire-orphans'); @@ -56,9 +57,9 @@ export function readAgeDays() { return n; } -/** Only the literal string 'false' disables; default enabled. */ +/** Only the literal string 'false' disabled; default enabled. Now DB-driven (flag.kg.retireOrphans). */ export function isEnabled() { - return process.env.KG_RETIRE_ORPHANS_ENABLED !== 'false'; + return isFlagEnabled('KG_RETIRE_ORPHANS_ENABLED'); } // --------------------------------------------------------------------------- diff --git a/srv/jobs/metrics-rollup-job.js b/srv/jobs/metrics-rollup-job.js index d68b884e5..df4d3505e 100644 --- a/srv/jobs/metrics-rollup-job.js +++ b/srv/jobs/metrics-rollup-job.js @@ -9,6 +9,7 @@ import cds from '@sap/cds'; import * as metrics from '../lib/metrics.js'; +import { isFlagEnabled } from '../lib/feature-flags/db-flags.js'; const NAMESPACE = 'com.sap.developers.ims'; const INSTANCE_ID = process.env.CF_INSTANCE_GUID || `local-${process.pid}`; @@ -22,7 +23,7 @@ function alignedWindowStart() { * @param {{ instanceId?: string }} [opts] — instanceId override for tests. */ export async function runMetricsRollup(opts = {}) { - if (process.env.METRICS_ENABLED === 'false') return { skipped: true }; + if (!isFlagEnabled('METRICS_ENABLED')) return { skipped: true }; const instanceId = opts.instanceId || INSTANCE_ID; const windowStart = alignedWindowStart(); diff --git a/srv/knowledge-graph-service.js b/srv/knowledge-graph-service.js index 60f62fe8d..81989c1b9 100644 --- a/srv/knowledge-graph-service.js +++ b/srv/knowledge-graph-service.js @@ -555,6 +555,7 @@ import { searchKgHandler } from './lib/kg/search-kg-handler.js'; import { embed as embedInputs } from './lib/embedding-client.js'; import { resolveEmbeddingSettings } from './lib/chat-settings-resolver.js'; import * as metrics from './lib/metrics.js'; +import { isFlagEnabled } from './lib/feature-flags/db-flags.js'; import { handleSharedConcepts, handleNeighborhood, handleSearchConcepts, handleCommunity } from './lib/mcp-kg-tools.js'; const NAMESPACE = 'com.sap.developers.ims'; @@ -1108,7 +1109,7 @@ export default cds.service.impl(async function () { // (LRU-cached, 5-min TTL, single-flight in-file) and pass as the 5th // positional arg. When off, EMPTY_RANK_MAPS collapses every multiplier // to 1.0, so the arm output is identical to pre-#916. - const rankMaps = process.env.KG_PAGERANK_ENABLED === 'true' + const rankMaps = isFlagEnabled('KG_PAGERANK_ENABLED') ? await loadRankMaps() : EMPTY_RANK_MAPS; const ranked = rankNeighborhood(rows, slug, coMap, tutorialTeachesMap, rankMaps); @@ -1318,7 +1319,7 @@ export default cds.service.impl(async function () { // 7. Rank with raised per-section cap (30 vs the sidebar's 10) AND // flag-gated PageRank blend (#916). The 6th positional arg carries // the cap; the 5th carries rankMaps (or EMPTY_RANK_MAPS when off). - const rankMaps = process.env.KG_PAGERANK_ENABLED === 'true' + const rankMaps = isFlagEnabled('KG_PAGERANK_ENABLED') ? await loadRankMaps() : EMPTY_RANK_MAPS; const ranked = rankNeighborhood( @@ -1465,7 +1466,7 @@ export default cds.service.impl(async function () { ? globalThis.__KG_QUERY_TEST_IMPL__ : kgQuery; - if (process.env.KG_PATH_V2_ENABLED === 'true') { + if (isFlagEnabled('KG_PATH_V2_ENABLED')) { try { const paths = await kgPathV2Impl({ fromIri, toIri }); if (paths.length > 0) { diff --git a/srv/lib/__tests__/metrics.test.js b/srv/lib/__tests__/metrics.test.js index 393e5ec34..090866af0 100644 --- a/srv/lib/__tests__/metrics.test.js +++ b/srv/lib/__tests__/metrics.test.js @@ -1,10 +1,19 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// METRICS_ENABLED moved from an env var to the ImsConfig-backed feature flag +// (issue #2060). Mock the DB-flag resolver so tests toggle the kill switch +// without a DB. `state.metricsFlag` is read at call time by isFlagEnabled. +const state = vi.hoisted(() => ({ metricsFlag: true })); +vi.mock('../feature-flags/db-flags.js', () => ({ + isFlagEnabled: (key) => (key === 'METRICS_ENABLED' ? state.metricsFlag : true), +})); + import * as metrics from '../metrics.js'; describe('metrics module (counters + gauges)', () => { beforeEach(() => { metrics._resetForTest(); - delete process.env.METRICS_ENABLED; + state.metricsFlag = true; }); it('counter() increments a named counter starting from 0', () => { @@ -27,8 +36,8 @@ describe('metrics module (counters + gauges)', () => { expect(snap).toEqual({ counters: {}, gauges: {}, histograms: {} }); }); - it('is a no-op when METRICS_ENABLED=false (still returns stable shape)', () => { - process.env.METRICS_ENABLED = 'false'; + it('is a no-op when the METRICS_ENABLED flag is off (still returns stable shape)', () => { + state.metricsFlag = false; metrics.counter('foo'); metrics.gauge('bar', 42); const snap = metrics.snapshot(); @@ -95,7 +104,7 @@ describe('metrics module (counters + gauges)', () => { describe('metrics module (histograms)', () => { beforeEach(() => { metrics._resetForTest(); - delete process.env.METRICS_ENABLED; + state.metricsFlag = true; }); it('observe() records samples and snapshot() returns count/p50/p95/p99/max', () => { @@ -126,8 +135,8 @@ describe('metrics module (histograms)', () => { expect(snap.histograms).toEqual({}); }); - it('observe() no-op when METRICS_ENABLED=false', () => { - process.env.METRICS_ENABLED = 'false'; + it('observe() no-op when the METRICS_ENABLED flag is off', () => { + state.metricsFlag = false; metrics.observe('latency', 42); expect(metrics.snapshot().histograms).toEqual({}); }); @@ -136,7 +145,7 @@ describe('metrics module (histograms)', () => { describe('metrics module (rotate + emitLogLine)', () => { beforeEach(() => { metrics._resetForTest(); - delete process.env.METRICS_ENABLED; + state.metricsFlag = true; }); it('rotate() returns the current snapshot and drains state', () => { diff --git a/srv/lib/chrome-shell.js b/srv/lib/chrome-shell.js index 8d51959bb..fb1f51aa6 100644 --- a/srv/lib/chrome-shell.js +++ b/srv/lib/chrome-shell.js @@ -16,6 +16,7 @@ import cds from '@sap/cds'; import { gunzipSync } from 'node:zlib'; import { Readable } from 'node:stream'; +import { isDeltaRead } from './content-delta-flags.js'; const SHELL_SLUG = '__shell__'; const MARKER = ''; @@ -242,7 +243,7 @@ export function createShellLoader({ namespace, hanaTableName, hanaCurrentTableNa const isHana = db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; // Option B: prefer the mutable ContentCurrent when the read flag is on, with // a per-slug fallback to the legacy version-pinned ContentFiles snapshot. - const useCurrent = process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent && typeof hanaCurrentTableName === 'function'; + const useCurrent = isDeltaRead() && ContentCurrent && typeof hanaCurrentTableName === 'function'; async function readBuf(fromCurrent) { let b; diff --git a/srv/lib/community-blogs-classifier.js b/srv/lib/community-blogs-classifier.js index 479068ded..169e73585 100644 --- a/srv/lib/community-blogs-classifier.js +++ b/srv/lib/community-blogs-classifier.js @@ -11,7 +11,7 @@ // Second ERROR sets attemptCount=2 and the row is sticky until the // admin fires reclassifyCommunityBlogPost to reset it. // -// Kill switch: env COMMUNITY_BLOGS_CLASSIFIER_ENABLED=false → no-op. +// Kill switch: ImsConfig flag.community.blogsClassifier=false → no-op. import cds from '@sap/cds'; import fs from 'node:fs'; @@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url'; import { OrchestrationClient } from '@sap-ai-sdk/orchestration'; import { resolveChatLlmSettings } from './chat-settings-resolver.js'; import * as metrics from './metrics.js'; +import { isFlagEnabled } from './feature-flags/db-flags.js'; const LOG = cds.log('community-blogs-classifier'); @@ -36,13 +37,12 @@ const SYSTEM_PROMPT = fs.readFileSync( ); /** - * Check the env kill switch. + * Check the DB-driven kill switch (ImsConfig flag.community.blogsClassifier). * @returns {boolean} */ export function isClassifierEnabled() { - const v = process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED; - // Only the exact literal 'false' disables — undefined / '' / 'true' all enable. - return v !== 'false'; + // Default ON; an admin sets the flag false to disable all classification runs. + return isFlagEnabled('COMMUNITY_BLOGS_CLASSIFIER_ENABLED'); } /** diff --git a/srv/lib/content-delta-flags.js b/srv/lib/content-delta-flags.js new file mode 100644 index 000000000..c391ff0ac --- /dev/null +++ b/srv/lib/content-delta-flags.js @@ -0,0 +1,175 @@ +// srv/lib/content-delta-flags.js +// +// Workstream D Option-B feature flags, moved from process.env.* to DB config in +// the ImsConfig key/value table (Tom prefers DB-driven admin config over env +// vars). Three flags gate the migration off the legacy ContentFiles snapshot +// model onto the mutable ContentCurrent model, flipped in order: +// +// content.delta.write (CONTENT_DELTA_WRITE_ENABLED) +// content.delta.read (CONTENT_DELTA_READ_ENABLED) +// content.delta.skipCarryForward (CONTENT_DELTA_SKIP_CARRYFORWARD) +// +// This is a PROD content-serving hot path: `resolveContentBlob` and the serve +// handlers consult isDeltaRead() on every request. The getters are therefore +// SYNCHRONOUS and NEVER block or throw — they return the last-known cached +// boolean and (when stale) kick off a fire-and-forget background refresh. +// +// Fail-safe default is FALSE (the legacy ContentFiles path) so a cold cache or +// any DB read error never silently enables delta reads/writes. Mirrors the +// cached-DB-flag pattern in ngds-autosend.js. + +import cds from '@sap/cds'; + +const NS = 'com.sap.developers.ims'; + +// ImsConfig keys (string values, 'true'/'false'). +export const DELTA_WRITE_KEY = 'content.delta.write'; +export const DELTA_READ_KEY = 'content.delta.read'; +export const DELTA_SKIP_CARRYFORWARD_KEY = 'content.delta.skipCarryForward'; + +const ALL_KEYS = [DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY]; + +// 60s TTL: short enough that an admin toggle takes effect within a minute even +// without an explicit cache bust (matches the ngds-autosend + alert windows). +export const FLAG_TTL_MS = 60 * 1000; + +// A single warm cache object refreshed together (one SELECT of the 3 keys). +// `at === 0` means never-loaded → every getter reports the fail-safe default +// (false) and triggers a background refresh. +let _cache = { + write: false, + read: false, + skipCarryForward: false, + at: 0, +}; + +// Guards against stacking concurrent background refreshes when many hot-path +// getters observe a stale cache in the same tick. +let _refreshing = null; + +function isFresh() { + return _cache.at !== 0 && Date.now() - _cache.at < FLAG_TTL_MS; +} + +/** + * Fire-and-forget background refresh, deduplicated. Never throws. + */ +function scheduleRefresh() { + if (_refreshing) return; + _refreshing = refreshContentDeltaFlags() + .catch(() => {}) + .finally(() => { _refreshing = null; }); +} + +/** + * Reload all three flags from ImsConfig in a single SELECT and update the cache + * + timestamp. Fail-safe: on ANY DB error, the last-known cache values are kept + * (a warm cache is never clobbered by a transient fault) and only the timestamp + * is refreshed so we don't hammer the DB on every getter during an outage. + * On a cold cache (never loaded) a fault leaves the safe defaults (all false). + * + * @returns {Promise<{write:boolean,read:boolean,skipCarryForward:boolean}>} + */ +export async function refreshContentDeltaFlags() { + try { + const db = await cds.connect.to('db'); + const { ImsConfig } = cds.entities(NS); + const rows = await db.run( + SELECT.from(ImsConfig).columns('key', 'value').where({ key: { in: ALL_KEYS } }) + ); + const byKey = Object.create(null); + for (const r of rows || []) byKey[r.key] = String(r.value).toLowerCase() === 'true'; + _cache = { + write: byKey[DELTA_WRITE_KEY] === true, + read: byKey[DELTA_READ_KEY] === true, + skipCarryForward: byKey[DELTA_SKIP_CARRYFORWARD_KEY] === true, + at: Date.now(), + }; + } catch (err) { + // Keep last-known values; do not fail the caller. Bump the timestamp so a + // burst of getters during a DB hiccup doesn't schedule a refresh storm. + cds.log('content-delta-flags').warn( + 'content delta flag refresh failed; keeping last-known values:', + err.message + ); + _cache = { ..._cache, at: Date.now() }; + } + return { write: _cache.write, read: _cache.read, skipCarryForward: _cache.skipCarryForward }; +} + +/** + * Drop the cache so the next getter (and any explicit refresh) re-reads from the + * DB. Called by the admin toggle so a flip takes effect immediately rather than + * after the TTL window. + */ +export function bustContentDeltaFlagsCache() { + _cache = { write: false, read: false, skipCarryForward: false, at: 0 }; + _refreshing = null; +} + +/** + * Seed the three delta flags to 'true' for any key whose ImsConfig row is + * ABSENT. Run once on boot BEFORE the warm refresh so the fast path defaults ON + * and a fresh deploy never silently reverts to the legacy path (the failure mode + * that dropped the fast path on the 1.20.0 deploy, when the flags lived in env + * vars the blue-green swap discarded). Now that they are data, this makes the + * ON state durable across deploys. + * + * Only INSERTs missing keys — a key an admin already set (true OR false) has a + * row, which this leaves untouched, so a deliberate disable survives deploys. + * ImsConfig is `cuid`, so set ID explicitly (a raw db.run INSERT does not + * auto-fill the UUID key on HANA — cds-db-insert-omitting-uuid-key gotcha). + * Fail-open: never throws into boot. + * + * @returns {Promise} the keys that were seeded (empty if all present) + */ +export async function ensureContentDeltaDefaults() { + try { + const db = await cds.connect.to('db'); + const { ImsConfig } = cds.entities(NS); + const rows = await db.run( + SELECT.from(ImsConfig).columns('key').where({ key: { in: ALL_KEYS } }) + ); + const present = new Set((rows || []).map((r) => r.key)); + const missing = ALL_KEYS.filter((k) => !present.has(k)); + if (missing.length) { + await db.run( + INSERT.into(ImsConfig).entries( + missing.map((key) => ({ ID: cds.utils.uuid(), key, value: 'true' })) + ) + ); + cds.log('content-delta-flags').info( + `seeded default-ON for absent delta flags: ${missing.join(', ')}` + ); + } + return missing; + } catch (err) { + cds.log('content-delta-flags').warn( + 'default-ON seed failed (non-fatal):', + err.message + ); + return []; + } +} + +// --- Synchronous hot-path getters ----------------------------------------- +// Each returns the last-known boolean immediately. When the cache is stale (or +// cold) it kicks off a non-blocking background refresh and returns the CURRENT +// cached value. They never await and never throw. + +function readSync(field) { + if (!isFresh()) scheduleRefresh(); + return _cache[field] === true; +} + +export function isDeltaWrite() { + return readSync('write'); +} + +export function isDeltaRead() { + return readSync('read'); +} + +export function isDeltaSkipCarryForward() { + return readSync('skipCarryForward'); +} diff --git a/srv/lib/content-publish-session.js b/srv/lib/content-publish-session.js index aceb2caf0..ce3b12bfd 100644 --- a/srv/lib/content-publish-session.js +++ b/srv/lib/content-publish-session.js @@ -10,6 +10,7 @@ import { recomputeTutorialProgressBulkSQL } from './recompute-tutorial-progress- import { tutorialsTableInfo } from './_tutorials-table.js'; import { logPipelineStart, logPipelineEnd, logPipelineItem } from './pipeline-log.js'; import { resolveTutorialAuthor } from './resolve-tutorial-author.js'; +import { isDeltaWrite, isDeltaSkipCarryForward } from './content-delta-flags.js'; const LOG = cds.log('content-publish'); const LOCK_NAME = 'content-publish'; @@ -467,7 +468,7 @@ export function createSessionHelpers({ namespace }) { // is complete AND ContentCurrent is fully seeded — else serving breaks. let carriedForward = 0; let carriedSize = 0; - if (process.env.CONTENT_DELTA_SKIP_CARRYFORWARD !== 'true') { + if (!isDeltaSkipCarryForward()) { ({ carriedForward, carriedSize } = await carryForwardUnchanged(namespace, newVersion, hanaTableName, getActiveVersion)); } else { LOG.info('[content/publish/commit] Option B: carry-forward SKIPPED (O(changed) publish)'); @@ -479,7 +480,7 @@ export function createSessionHelpers({ namespace }) { // cut over behind a separate read flag with a safe fallback. Never throws // into the commit tx — the legacy write remains the source of truth until // the read cutover. - if (process.env.CONTENT_DELTA_WRITE_ENABLED === 'true') { + if (isDeltaWrite()) { try { const { written } = await dualWriteCurrentAndHistory(namespace, newVersion, freshSlugs, hanaTableName); LOG.info(`[content/publish/commit] Option B dual-write: ${written} slug(s) → ContentCurrent/ContentHistory`); diff --git a/srv/lib/content-store.js b/srv/lib/content-store.js index 90bc25d39..5ef81cda3 100644 --- a/srv/lib/content-store.js +++ b/srv/lib/content-store.js @@ -21,6 +21,7 @@ import { setContentCacheHeaders } from './edge-cache-headers.js'; import { pageKeyForPath, mimeTypeForPageKey } from './page-key-map.js'; import { loadPageFallback } from './page-fallback.js'; import { stampSubmissionId } from './task-record-submission-id.js'; +import { isDeltaWrite, isDeltaRead, isDeltaSkipCarryForward } from './content-delta-flags.js'; const LOG = cds.log('content-store'); const LOCK_NAME = 'content-publish'; @@ -324,7 +325,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap const { ContentFiles, ContentCurrent } = cds.entities(namespace); const db = await cds.connect.to('db'); const isHana = db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; - if (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) { + if (isDeltaRead() && ContentCurrent) { // slug-canonical: caller-canonicalizes const [cur] = await SELECT.from(ContentCurrent).where({ slug }).columns('contentHash', 'mimeType', 'sourceVersion'); if (cur) { @@ -1009,7 +1010,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap // ContentFiles snapshot when the flag is off OR the slug isn't in // ContentCurrent yet (mid-migration, before the full seed) — so a partially- // populated ContentCurrent never 404s a slug that still lives in ContentFiles. - const READ_DELTA = process.env.CONTENT_DELTA_READ_ENABLED === 'true'; + const READ_DELTA = isDeltaRead(); let meta; let source = 'files'; if (READ_DELTA && ContentCurrent) { @@ -1298,7 +1299,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap // — correct once ContentCurrent is fully seeded (task 4.3). Metadata-only, // so plain CQL is fine on HANA + SQLite (no LOB). Else legacy active snapshot. let rows; - if (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) { + if (isDeltaRead() && ContentCurrent) { rows = await SELECT.from(ContentCurrent).columns('slug', 'contentHash'); } else { const activeVersion = await getActiveVersion(); @@ -1341,7 +1342,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap async function sourceHashesHandler(req, res) { try { - const useCurrent = process.env.CONTENT_DELTA_READ_ENABLED === 'true'; + const useCurrent = isDeltaRead(); const db = await cds.connect.to('db'); const isHana = db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; @@ -1449,7 +1450,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap // Option B: read source columns from ContentCurrent (read flag on) with a // fallback to the legacy active-version snapshot. let row = null; - if (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) { + if (isDeltaRead() && ContentCurrent) { if (isHana) { const rows = await db.run( `SELECT TOP 1 "SOURCECONTENT", "SOURCEHASH", "CONTENTHASH" FROM "${hanaCurrentTableName()}" WHERE LOWER("SLUG") = ?`, @@ -1511,7 +1512,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap // Option B: enumerate ContentCurrent (no version) when the read flag is on — // correct once fully seeded (task 4.3). Metadata-only, plain CQL on both DBs. - const contentRows = (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) + const contentRows = (isDeltaRead() && ContentCurrent) ? await SELECT.from(ContentCurrent).columns('slug', 'sizeBytes') : await SELECT.from(ContentFiles).where({ version: activeVersion }).columns('slug', 'sizeBytes'); @@ -1688,7 +1689,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap .set({ status: 'ACTIVE' }); // Option B rollback (Workstream D), flag-gated + fail-safe. - if (process.env.CONTENT_DELTA_SKIP_CARRYFORWARD === 'true') { + if (isDeltaSkipCarryForward()) { // Steady state: carry-forward is off, so ContentFiles(target.version) is // NOT a complete snapshot to fall back to — replay ContentCurrent from // ContentHistory to restore the exact state as of target.version. @@ -1698,7 +1699,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap } catch (e) { console.error('[content/rollback] Option B replay FAILED — ContentCurrent may be inconsistent, re-run rollback:', e.message); } - } else if (process.env.CONTENT_DELTA_WRITE_ENABLED === 'true') { + } else if (isDeltaWrite()) { // Dual-write migration window: ContentFiles is still the full, authoritative // snapshot, so the flip above already restored content as of target.version. // Clearing ContentCurrent makes every read fall back to that restored diff --git a/srv/lib/embedding-pipeline.js b/srv/lib/embedding-pipeline.js index 03ea60d81..d30d62a3b 100644 --- a/srv/lib/embedding-pipeline.js +++ b/srv/lib/embedding-pipeline.js @@ -4,6 +4,7 @@ import { extractStepText } from './step-text-extractor.js'; import { embed } from './embedding-client.js'; import { acquireLock, releaseLock } from '../jobs/job-lock.js'; import { toBuffer } from './content-store.js'; +import { isDeltaRead } from './content-delta-flags.js'; const LOG = cds.log('embedding-pipeline'); const LOCK_NAME = 'embedding-pipeline'; @@ -23,7 +24,7 @@ async function readContentBuffer(db, slug) { const isHana = db.options?.kind === 'hana' || db.constructor?.name === 'HANAService'; // Option B: prefer the mutable ContentCurrent (read flag on), fall back to the // legacy active-manifest snapshot on a miss. - if (process.env.CONTENT_DELTA_READ_ENABLED === 'true') { + if (isDeltaRead()) { if (isHana) { const [row] = await db.run( `SELECT TOP 1 "CONTENT" FROM "COM_SAP_DEVELOPERS_IMS_CONTENTCURRENT" WHERE "SLUG" = ?`, [slug]); diff --git a/srv/lib/embedding-stats.js b/srv/lib/embedding-stats.js index 078b61857..dfc75d969 100644 --- a/srv/lib/embedding-stats.js +++ b/srv/lib/embedding-stats.js @@ -9,6 +9,7 @@ // track what visitors are actually reading, not orphaned historical rows. import cds from '@sap/cds'; +import { isDeltaRead } from './content-delta-flags.js'; const LOG = cds.log('rag-stats'); @@ -64,7 +65,7 @@ export async function computeEmbeddingStats() { // Active slugs — from ContentCurrent when the read flag is on (Option B), else // the legacy manifest-version snapshot. - const files = (process.env.CONTENT_DELTA_READ_ENABLED === 'true' && ContentCurrent) + const files = (isDeltaRead() && ContentCurrent) ? await SELECT.from(ContentCurrent).columns('slug') : await SELECT.from(ContentFiles).columns('slug').where({ version: manifest.version }); const slugs = files.map((f) => f.slug); diff --git a/srv/lib/feature-flags/db-flags.js b/srv/lib/feature-flags/db-flags.js new file mode 100644 index 000000000..8e394e37c --- /dev/null +++ b/srv/lib/feature-flags/db-flags.js @@ -0,0 +1,232 @@ +// srv/lib/feature-flags/db-flags.js +// +// Generic, registry-driven ImsConfig-backed feature-flag resolver (issue #2060). +// Moves the remaining on/off `kind:'env'` feature flags out of process.env and +// into the ImsConfig key/value table (Tom prefers DB-driven admin config over +// env vars). Mirrors the cached-DB-flag pattern established by +// srv/lib/content-delta-flags.js, but generalized: the set of managed flags and +// their per-flag defaults are read from srv/lib/feature-flags/registry.js — any +// `kind:'db'` + `valueType:'boolean'` entry with an `imsConfigKey` is managed +// here, so adding a new DB boolean flag needs no change to this file. +// +// EXCEPTION: the Content Option-B delta flags (imsConfigKey content.delta.*) +// keep their OWN dedicated module (content-delta-flags.js) — they seed to TRUE +// (fast-path-ON) rather than to their declared registry default, and they carry +// bespoke synchronous hot-path getters. To avoid a double-seed / double-cache +// conflict they are explicitly excluded from this generic manager. +// +// Contract (mirrors content-delta-flags.js): +// - isFlagEnabled(registryKey) is SYNCHRONOUS. It returns the last-known +// cached boolean; when the cache is cold or stale it kicks off a +// fire-and-forget background refresh and returns the current value. +// - Fail-safe on a COLD cache or a DB read error is the flag's DECLARED +// registry default (NOT a blanket false) — so a `false-disables` kill +// switch stays ON (true) through a DB hiccup, and a `true-enables` flag +// stays OFF (false). Never blocks, never throws. + +import cds from '@sap/cds'; +import { FEATURE_FLAGS } from './registry.js'; + +const LOG = cds.log('feature-flags'); +const NS = 'com.sap.developers.ims'; + +// 60s TTL — an admin toggle takes effect within a minute even without an +// explicit bust (matches the content-delta + ngds-autosend + alert windows). +export const FLAG_TTL_MS = 60 * 1000; + +// ImsConfig keys owned by the dedicated content-delta-flags.js module — never +// managed (seeded/cached) here. See the header note. +const EXCLUDED_IMS_KEYS = new Set([ + 'content.delta.write', + 'content.delta.read', + 'content.delta.skipCarryForward', +]); + +// Static map built once from the registry: registryKey -> { imsConfigKey, +// default:boolean }. The source of truth is registry.js. +const DB_FLAGS = new Map(); +for (const f of FEATURE_FLAGS) { + if ( + f.kind === 'db' && + f.valueType === 'boolean' && + f.imsConfigKey && + !EXCLUDED_IMS_KEYS.has(f.imsConfigKey) + ) { + DB_FLAGS.set(f.key, { imsConfigKey: f.imsConfigKey, default: Boolean(f.default) }); + } +} + +// All ImsConfig keys this module manages — one SELECT covers them all. +const ALL_IMS_KEYS = [...DB_FLAGS.values()].map((m) => m.imsConfigKey); + +// Warm cache + refresh guard live on globalThis so EVERY module instance shares +// one cache. CAP loads service modules via a dynamic file:// import whose URL +// can differ from a static import's (e.g. Windows drive-letter case), yielding +// separate ESM instances of this file; a module-local `let` cache would then be +// per-instance (a service and its test, or two services, would not see each +// other's toggles). Keying the state on globalThis (same pattern as the +// __metricsDbWrapInstalled sentinel in server.js) makes the flag cache process- +// global. `at === 0` means never-loaded → every getter reports the per-flag +// declared default and triggers a background refresh; `values` maps +// imsConfigKey -> boolean (an absent key also falls back to the declared default). +const STATE = (globalThis.__imsFeatureFlagsState__ ??= { + values: Object.create(null), + at: 0, + refreshing: null, +}); + +function isFresh() { + return STATE.at !== 0 && Date.now() - STATE.at < FLAG_TTL_MS; +} + +/** Fire-and-forget background refresh, deduplicated. Never throws. */ +function scheduleRefresh() { + if (STATE.refreshing) return; + // Do NOT trigger a `cds.connect.to('db')` before the model is loaded. A + // synchronous isFlagEnabled() reader can fire during the `cds.on('bootstrap')` + // phase (e.g. the MCP_AUTH_ENABLED kill switch in server.js) — at that point + // `cds.model` is not yet linked. cds.connect.to('db') caches cds.db eagerly + // (cds.services.db = the pending promise) and resolves its model from the + // partial, service-less model that exists mid-bootstrap; that poisoned db + // instance then persists, so cqn4sql can't resolve service-qualified targets + // and every later read throws "Query was not inferred and includes '*'". + // Skipping the refresh here is harmless: the getter returns the flag's + // declared registry default (exactly the intended cold-cache behavior) and + // the boot warm-up in server.js's `served` handler performs the real refresh + // once the model is fully loaded. Runtime-safe on every dialect (the poison + // is model resolution, not SQLite-specific). + if (!cds.model) return; + STATE.refreshing = refreshFeatureFlags() + .catch(() => {}) + .finally(() => { STATE.refreshing = null; }); +} + +/** + * Reload every managed flag from ImsConfig in ONE SELECT and update the cache + + * timestamp. Fail-safe: on ANY DB error the last-known cache values are kept (a + * warm cache is never clobbered by a transient fault) and only the timestamp is + * bumped so a burst of getters during an outage doesn't schedule a refresh + * storm. On a cold cache a fault leaves `values` empty → every getter returns + * its declared default. + * + * @returns {Promise>} imsConfigKey -> boolean + */ +export async function refreshFeatureFlags() { + try { + const db = await cds.connect.to('db'); + const { ImsConfig } = cds.entities(NS); + const rows = await db.run( + SELECT.from(ImsConfig).columns('key', 'value').where({ key: { in: ALL_IMS_KEYS } }) + ); + const values = Object.create(null); + for (const r of rows || []) values[r.key] = String(r.value).toLowerCase() === 'true'; + STATE.values = values; + STATE.at = Date.now(); + } catch (err) { + LOG.warn('feature flag refresh failed; keeping last-known values:', err.message); + STATE.at = Date.now(); + } + return { ...STATE.values }; +} + +/** + * Drop the cache so the next getter (and any explicit refresh) re-reads the DB. + * Called by the admin toggle so a flip takes effect immediately rather than + * after the TTL window. + */ +export function bustFeatureFlagsCache() { + STATE.values = Object.create(null); + STATE.at = 0; + STATE.refreshing = null; +} + +/** + * Seed any ABSENT managed imsConfigKey with its registry default ('true' / + * 'false'). Run once on boot BEFORE the warm refresh so the DB carries an + * explicit row for every flag and the effective value is durable across deploys + * (data, not env — env vars were discarded by the blue-green swap). + * + * Only INSERTs missing keys — a key an admin already set (true OR false) has a + * row this leaves untouched, so a deliberate override survives deploys. + * ImsConfig is `cuid`, so set ID explicitly (a raw db.run INSERT does not + * auto-fill the UUID key on HANA — cds-db-insert-omitting-uuid-key gotcha). + * Fail-open: never throws into boot. + * + * @returns {Promise} the imsConfigKeys that were seeded (empty if all present) + */ +export async function ensureFeatureFlagDefaults() { + try { + const db = await cds.connect.to('db'); + const { ImsConfig } = cds.entities(NS); + const rows = await db.run( + SELECT.from(ImsConfig).columns('key').where({ key: { in: ALL_IMS_KEYS } }) + ); + const present = new Set((rows || []).map((r) => r.key)); + const missing = [...DB_FLAGS.values()].filter((m) => !present.has(m.imsConfigKey)); + if (missing.length) { + await db.run( + INSERT.into(ImsConfig).entries( + missing.map((m) => ({ ID: cds.utils.uuid(), key: m.imsConfigKey, value: String(m.default) })) + ) + ); + LOG.info( + `seeded defaults for absent feature flags: ${missing.map((m) => m.imsConfigKey).join(', ')}` + ); + } + return missing.map((m) => m.imsConfigKey); + } catch (err) { + LOG.warn('feature-flag default seed failed (non-fatal):', err.message); + return []; + } +} + +/** + * SYNCHRONOUS effective boolean for a managed feature flag, keyed by its + * REGISTRY key (e.g. 'METRICS_ENABLED', 'KG_PAGERANK_ENABLED'). Returns the + * cached value; when the cache is cold/stale it kicks off a non-blocking + * background refresh and returns the current value. Fail-safe (cold cache, DB + * error, or unset DB row) is the flag's DECLARED registry default. Never blocks, + * never throws. + * + * @param {string} registryKey + * @returns {boolean} + */ +export function isFlagEnabled(registryKey) { + const meta = DB_FLAGS.get(registryKey); + if (!meta) { + // Not a managed DB boolean flag — defensive, should not happen in practice. + LOG.warn(`isFlagEnabled: unknown feature flag '${registryKey}' — returning false`); + return false; + } + if (!isFresh()) scheduleRefresh(); + const v = STATE.values[meta.imsConfigKey]; + return v === undefined ? meta.default : v === true; +} + +// Test/introspection helper — the registry keys this module manages. +export function managedFlagKeys() { + return [...DB_FLAGS.keys()]; +} + +// Metadata for one managed flag by its registry key, or null if unmanaged. +// Used by the AdminService setFeatureFlag/getFeatureFlags actions. +export function flagMeta(registryKey) { + const m = DB_FLAGS.get(registryKey); + return m ? { imsConfigKey: m.imsConfigKey, default: m.default } : null; +} + +// Test-only: force a flag's cached value (and mark the cache fresh) without a +// DB round-trip, so a unit test can toggle a kill switch deterministically. +// NOT for production use. +export function __setFlagForTest(registryKey, enabled) { + const meta = DB_FLAGS.get(registryKey); + if (!meta) return; + STATE.values[meta.imsConfigKey] = Boolean(enabled); + STATE.at = Date.now(); +} + +// Test-only: clear any forced cache state back to cold (every flag reads its +// declared default until the next refresh). +export function __resetFlagsForTest() { + bustFeatureFlagsCache(); +} diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index a109a1b88..8fb415355 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -9,19 +9,31 @@ // `resolver` picks how the effective value is resolved: // 'kg'/'uiEvents' → the env-layered resolveXSettings() // 'chat' → direct ChatSettings row (no env layer) +// 'db' — an ImsConfig key/value row (string 'true'/'false'); the +// effective value is read live from ImsConfig by `imsConfigKey`. +// No env layer — admin-toggle / SQL upsert only. // 'constant' — a hardcoded, non-runtime-configurable value (shown, no howToChange). // envRule: 'true-enables' | 'false-disables' | 'numeric'. // status: 'ga' | 'dev-only' | 'beta' | 'parked'. -export const KINDS = ['env', 'db-setting', 'constant']; +export const KINDS = ['env', 'db-setting', 'db', 'constant']; export const ENV_RULES = ['true-enables', 'false-disables', 'numeric']; export const STATUSES = ['ga', 'dev-only', 'beta', 'parked']; -const cfEnv = (name, value) => ({ - method: 'cf-env', - command: `cf set-env tutorials-srv ${name} ${value} && cf restart tutorials-srv`, -}); const adminTile = (tile, hash, note) => ({ method: 'admin-tile', tile, hash, note }); +// ImsConfig-backed DB flag (kind:'db'): flipped via the AdminService +// setContentDeltaFlags action or a direct ImsConfig upsert. No env var. +const imsConfigUpsert = (imsKey) => ({ + method: 'db-upsert', + text: `AdminService.setContentDeltaFlags action (busts cache), or UPSERT ImsConfig key '${imsKey}' = 'true'/'false'.`, +}); +// Generic ImsConfig-backed DB feature flag (kind:'db', #2060): flipped via the +// AdminService setFeatureFlag(key, enabled) action (busts the 60s cache) or a +// direct ImsConfig upsert. No env var — read live through srv/lib/feature-flags/db-flags.js. +const featureFlagUpsert = (registryKey, imsKey) => ({ + method: 'db-upsert', + text: `AdminService.setFeatureFlag(key:'${registryKey}', enabled) action (busts cache), or UPSERT ImsConfig key '${imsKey}' = 'true'/'false'.`, +}); export const FEATURE_FLAGS = [ // ---- Knowledge Graph env flags (env-layered via resolveKnowledgeGraphSettings where applicable) ---- @@ -44,17 +56,17 @@ export const FEATURE_FLAGS = [ }, { key: 'KG_PAGERANK_ENABLED', label: 'KG PageRank blend', category: 'Knowledge Graph', - kind: 'env', envVar: 'KG_PAGERANK_ENABLED', envRule: 'true-enables', + kind: 'db', imsConfigKey: 'flag.kg.pagerank', valueType: 'boolean', default: false, issue: '#916', status: 'ga', - description: 'Blends per-tutorial PageRank into KG neighborhood ranking.', - howToChange: cfEnv('KG_PAGERANK_ENABLED', 'true'), + description: 'Blends per-tutorial PageRank into KG neighborhood ranking. DB-driven config (ImsConfig key flag.kg.pagerank); no env var. Default OFF.', + howToChange: featureFlagUpsert('KG_PAGERANK_ENABLED', 'flag.kg.pagerank'), }, { key: 'KG_PATH_V2_ENABLED', label: 'KG path-finding v2', category: 'Knowledge Graph', - kind: 'env', envVar: 'KG_PATH_V2_ENABLED', envRule: 'true-enables', + kind: 'db', imsConfigKey: 'flag.kg.pathV2', valueType: 'boolean', default: false, issue: '#913', status: 'beta', - description: 'Property-graph v2 pathBetween with fail-open v1 SPARQL fallback.', - howToChange: cfEnv('KG_PATH_V2_ENABLED', 'true'), + description: 'Property-graph v2 pathBetween with fail-open v1 SPARQL fallback. DB-driven config (ImsConfig key flag.kg.pathV2); no env var. Default OFF.', + howToChange: featureFlagUpsert('KG_PATH_V2_ENABLED', 'flag.kg.pathV2'), }, { key: 'communityRankWeight', label: 'KG community search weight', @@ -190,110 +202,110 @@ export const FEATURE_FLAGS = [ }, { key: 'METRICS_ENABLED', label: 'Metrics collection', category: 'Observability', - kind: 'env', envVar: 'METRICS_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.metrics', valueType: 'boolean', default: true, issue: '', status: 'ga', - description: 'Prometheus-style metrics snapshots and DB wrap instrumentation. Kill switch — set false to disable all metric writes.', - howToChange: cfEnv('METRICS_ENABLED', 'false'), + description: 'Prometheus-style metrics snapshots and DB wrap instrumentation. Kill switch — set false to disable all metric writes. DB-driven config (ImsConfig key flag.metrics); no env var. Default ON.', + howToChange: featureFlagUpsert('METRICS_ENABLED', 'flag.metrics'), }, // ---- MCP (Phase 2 / Phase 3) ---- { key: 'MCP_AUTH_ENABLED', label: 'MCP OAuth auth tier', category: 'MCP', - kind: 'env', envVar: 'MCP_AUTH_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.mcp.auth', valueType: 'boolean', default: true, issue: '#1105', status: 'ga', - description: 'Phase 2 MCP /mcp-auth and /mcp-pat routes. Kill switch — set false to return 503 on both routes.', - howToChange: cfEnv('MCP_AUTH_ENABLED', 'false'), + description: 'Phase 2 MCP /mcp-auth and /mcp-pat routes. Kill switch — set false to return 503 on both routes. DB-driven config (ImsConfig key flag.mcp.auth); no env var. Default ON. NOTE: the boot-time route mount reads this on a cold cache and so honors the declared default (ON) at boot; the DB value gates the per-request paths and takes full effect after the warm-up / next restart.', + howToChange: featureFlagUpsert('MCP_AUTH_ENABLED', 'flag.mcp.auth'), }, { key: 'MCP_PAT_MINT_ENABLED', label: 'MCP PAT minting', category: 'MCP', - kind: 'env', envVar: 'MCP_PAT_MINT_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.mcp.patMint', valueType: 'boolean', default: true, issue: '#1105', status: 'ga', - description: 'Allows PAT tokens to be minted via the MCP auth tier. Kill switch — set false to disable minting (existing PATs still valid).', - howToChange: cfEnv('MCP_PAT_MINT_ENABLED', 'false'), + description: 'Allows PAT tokens to be minted via the MCP auth tier. Kill switch — set false to disable minting (existing PATs still valid). DB-driven config (ImsConfig key flag.mcp.patMint); no env var. Default ON.', + howToChange: featureFlagUpsert('MCP_PAT_MINT_ENABLED', 'flag.mcp.patMint'), }, { key: 'MCP_PHASE3_ENABLED', label: 'MCP Phase-3 compose router', category: 'MCP', - kind: 'env', envVar: 'MCP_PHASE3_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.mcp.phase3', valueType: 'boolean', default: true, issue: '#1106', status: 'ga', - description: 'MCP Phase-3 compose router (resources + prompts + admin tools). Kill switch — set false to serve tools-only via plain @cap-js/mcp adapter.', - howToChange: cfEnv('MCP_PHASE3_ENABLED', 'false'), + description: 'MCP Phase-3 compose router (resources + prompts + admin tools). Kill switch — set false to serve tools-only via plain @cap-js/mcp adapter. DB-driven config (ImsConfig key flag.mcp.phase3); no env var. Default ON. NOTE: the boot-time compose-router mount reads this on a cold cache and so honors the declared default (ON) at boot; the per-request /mcp-admin gate uses the warm DB value.', + howToChange: featureFlagUpsert('MCP_PHASE3_ENABLED', 'flag.mcp.phase3'), }, { key: 'MCP_RESOURCES_ENABLED', label: 'MCP resources', category: 'MCP', - kind: 'env', envVar: 'MCP_RESOURCES_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.mcp.resources', valueType: 'boolean', default: true, issue: '#1106', status: 'ga', - description: 'MCP resource registration inside the Phase-3 compose router. Kill switch — set false to omit resources from the compose server.', - howToChange: cfEnv('MCP_RESOURCES_ENABLED', 'false'), + description: 'MCP resource registration inside the Phase-3 compose router. Kill switch — set false to omit resources from the compose server. DB-driven config (ImsConfig key flag.mcp.resources); no env var. Default ON.', + howToChange: featureFlagUpsert('MCP_RESOURCES_ENABLED', 'flag.mcp.resources'), }, { key: 'MCP_PROMPTS_ENABLED', label: 'MCP prompts', category: 'MCP', - kind: 'env', envVar: 'MCP_PROMPTS_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.mcp.prompts', valueType: 'boolean', default: true, issue: '#1106', status: 'ga', - description: 'MCP prompt registration inside the Phase-3 compose router. Kill switch — set false to omit prompts from the compose server.', - howToChange: cfEnv('MCP_PROMPTS_ENABLED', 'false'), + description: 'MCP prompt registration inside the Phase-3 compose router. Kill switch — set false to omit prompts from the compose server. DB-driven config (ImsConfig key flag.mcp.prompts); no env var. Default ON.', + howToChange: featureFlagUpsert('MCP_PROMPTS_ENABLED', 'flag.mcp.prompts'), }, { key: 'MCP_ADMIN_TOOLS_ENABLED', label: 'MCP admin tools', category: 'MCP', - kind: 'env', envVar: 'MCP_ADMIN_TOOLS_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.mcp.adminTools', valueType: 'boolean', default: true, issue: '#1106', status: 'ga', - description: 'MCP admin tool registration inside the Phase-3 compose router. Kill switch — set false to omit admin tools from the compose server.', - howToChange: cfEnv('MCP_ADMIN_TOOLS_ENABLED', 'false'), + description: 'MCP admin tool registration inside the Phase-3 compose router. Kill switch — set false to omit admin tools from the compose server. DB-driven config (ImsConfig key flag.mcp.adminTools); no env var. Default ON.', + howToChange: featureFlagUpsert('MCP_ADMIN_TOOLS_ENABLED', 'flag.mcp.adminTools'), }, // ---- Knowledge Graph kill switches ---- { key: 'KG_RETIRE_ORPHANS_ENABLED', label: 'KG orphan concept retirement', category: 'Knowledge Graph', - kind: 'env', envVar: 'KG_RETIRE_ORPHANS_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.kg.retireOrphans', valueType: 'boolean', default: true, issue: '#1115', status: 'ga', - description: 'Nightly job that retires zero-link orphaned concepts (ACTIVE→RETIRED). Kill switch — set false to skip retirement on each nightly run.', - howToChange: cfEnv('KG_RETIRE_ORPHANS_ENABLED', 'false'), + description: 'Nightly job that retires zero-link orphaned concepts (ACTIVE→RETIRED). Kill switch — set false to skip retirement on each nightly run. DB-driven config (ImsConfig key flag.kg.retireOrphans); no env var. Default ON.', + howToChange: featureFlagUpsert('KG_RETIRE_ORPHANS_ENABLED', 'flag.kg.retireOrphans'), }, { key: 'KG_STEP_SLICER_ENABLED', label: 'KG tutorial step slicer', category: 'Knowledge Graph', - kind: 'env', envVar: 'KG_STEP_SLICER_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.kg.stepSlicer', valueType: 'boolean', default: true, issue: '', status: 'ga', - description: 'Per-step concept extraction slice during tutorial ingestion. Kill switch — set false to skip step-level slicing (whole-tutorial extraction still runs).', - howToChange: cfEnv('KG_STEP_SLICER_ENABLED', 'false'), + description: 'Per-step concept extraction slice during tutorial ingestion. Kill switch — set false to skip step-level slicing (whole-tutorial extraction still runs). DB-driven config (ImsConfig key flag.kg.stepSlicer); no env var. Default ON.', + howToChange: featureFlagUpsert('KG_STEP_SLICER_ENABLED', 'flag.kg.stepSlicer'), }, // ---- Content ---- { key: 'COMMUNITY_BLOGS_CLASSIFIER_ENABLED', label: 'Community blogs classifier', category: 'Content', - kind: 'env', envVar: 'COMMUNITY_BLOGS_CLASSIFIER_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.community.blogsClassifier', valueType: 'boolean', default: true, issue: '#1033', status: 'ga', - description: 'Scheduled AI classifier that drains PENDING CommunityBlogPosts rows via SAP Generative AI Hub. Kill switch — set false to skip all classification runs.', - howToChange: cfEnv('COMMUNITY_BLOGS_CLASSIFIER_ENABLED', 'false'), + description: 'Scheduled AI classifier that drains PENDING CommunityBlogPosts rows via SAP Generative AI Hub. Kill switch — set false to skip all classification runs. DB-driven config (ImsConfig key flag.community.blogsClassifier); no env var. Default ON.', + howToChange: featureFlagUpsert('COMMUNITY_BLOGS_CLASSIFIER_ENABLED', 'flag.community.blogsClassifier'), }, { key: 'HOMEPAGE_NEWS_RELEVANCE_ENABLED', label: 'Homepage news relevance scoring', category: 'Content', - kind: 'env', envVar: 'HOMEPAGE_NEWS_RELEVANCE_ENABLED', envRule: 'false-disables', + kind: 'db', imsConfigKey: 'flag.homepage.newsRelevance', valueType: 'boolean', default: true, issue: '', status: 'ga', - description: 'AI-based relevance scoring for homepage news items. Kill switch — set false to fall back to chronological ordering.', - howToChange: cfEnv('HOMEPAGE_NEWS_RELEVANCE_ENABLED', 'false'), + description: 'AI-based relevance scoring for homepage news items. Kill switch — set false to fall back to chronological ordering. DB-driven config (ImsConfig key flag.homepage.newsRelevance); no env var. Default ON.', + howToChange: featureFlagUpsert('HOMEPAGE_NEWS_RELEVANCE_ENABLED', 'flag.homepage.newsRelevance'), }, { key: 'CONTENT_DELTA_WRITE_ENABLED', label: 'Content Option-B dual-write', category: 'Content', - kind: 'env', envVar: 'CONTENT_DELTA_WRITE_ENABLED', envRule: 'true-enables', + kind: 'db', imsConfigKey: 'content.delta.write', valueType: 'boolean', default: false, status: 'dev-only', - description: 'Workstream D Option B: on publish, mirror freshly-published slugs into ContentCurrent + ContentHistory alongside the legacy ContentFiles write. Fail-safe (never throws into the commit tx); legacy ContentFiles remains the source of truth until the read cutover. Default OFF.', - howToChange: cfEnv('CONTENT_DELTA_WRITE_ENABLED', 'true'), + description: 'Workstream D Option B: on publish, mirror freshly-published slugs into ContentCurrent + ContentHistory alongside the legacy ContentFiles write. Fail-safe (never throws into the commit tx); legacy ContentFiles remains the source of truth until the read cutover. DB-driven config (ImsConfig key content.delta.write); no env var. Default OFF.', + howToChange: imsConfigUpsert('content.delta.write'), }, { key: 'CONTENT_DELTA_READ_ENABLED', label: 'Content Option-B read from ContentCurrent', category: 'Content', - kind: 'env', envVar: 'CONTENT_DELTA_READ_ENABLED', envRule: 'true-enables', + kind: 'db', imsConfigKey: 'content.delta.read', valueType: 'boolean', default: false, status: 'dev-only', - description: 'Workstream D Option B: serve + readers source from the mutable ContentCurrent (per-slug fallback to legacy ContentFiles). Enable after ContentCurrent is fully seeded. Default OFF.', - howToChange: cfEnv('CONTENT_DELTA_READ_ENABLED', 'true'), + description: 'Workstream D Option B: serve + readers source from the mutable ContentCurrent (per-slug fallback to legacy ContentFiles). Enable after ContentCurrent is fully seeded. DB-driven config (ImsConfig key content.delta.read); no env var. Default OFF.', + howToChange: imsConfigUpsert('content.delta.read'), }, { key: 'CONTENT_DELTA_SKIP_CARRYFORWARD', label: 'Content Option-B skip carry-forward (O(changed) publish)', category: 'Content', - kind: 'env', envVar: 'CONTENT_DELTA_SKIP_CARRYFORWARD', envRule: 'true-enables', + kind: 'db', imsConfigKey: 'content.delta.skipCarryForward', valueType: 'boolean', default: false, status: 'dev-only', - description: 'Workstream D Option B: publish writes ONLY changed slugs (no carry-forward); rollback replays ContentHistory into ContentCurrent. Enable ONLY after the read cutover is live AND ContentCurrent is fully seeded. Default OFF.', - howToChange: cfEnv('CONTENT_DELTA_SKIP_CARRYFORWARD', 'true'), + description: 'Workstream D Option B: publish writes ONLY changed slugs (no carry-forward); rollback replays ContentHistory into ContentCurrent. Enable ONLY after the read cutover is live AND ContentCurrent is fully seeded. DB-driven config (ImsConfig key content.delta.skipCarryForward); no env var. Default OFF.', + howToChange: imsConfigUpsert('content.delta.skipCarryForward'), }, { key: 'FRESHNESS_SCAN_ENABLED', label: 'Tutorial freshness bulk scan', category: 'Content', - kind: 'env', envVar: 'FRESHNESS_SCAN_ENABLED', envRule: 'true-enables', + kind: 'db', imsConfigKey: 'flag.freshness.scan', valueType: 'boolean', default: false, status: 'dev-only', - description: 'When true, the nightly freshness-scan job runs the detector across the tutorial catalog. Default OFF.', - howToChange: cfEnv('FRESHNESS_SCAN_ENABLED', 'true'), + description: 'When true, the nightly freshness-scan job runs the detector across the tutorial catalog. DB-driven config (ImsConfig key flag.freshness.scan); no env var. Default OFF.', + howToChange: featureFlagUpsert('FRESHNESS_SCAN_ENABLED', 'flag.freshness.scan'), }, ]; diff --git a/srv/lib/feature-flags/resolve.js b/srv/lib/feature-flags/resolve.js index 339670ad4..b2cea7cff 100644 --- a/srv/lib/feature-flags/resolve.js +++ b/srv/lib/feature-flags/resolve.js @@ -53,6 +53,23 @@ export async function resolveFeatureFlags() { const resolvedByResolver = { kg: kgResolved, uiEvents: uiResolved, navigator: navResolved }; const rowByEntity = { KnowledgeGraphSettings: kgRow, UiEventsSettings: uiRow, ChatSettings: chatRow, NavigatorSettings: navRow }; + // Pre-fetch the ImsConfig key/value rows backing any kind:'db' flags in one + // SELECT, so the (synchronous) descriptor map below can look them up without + // awaiting. Tolerates a read failure (→ empty map → those flags read default). + const imsKeys = FEATURE_FLAGS.filter((f) => f.kind === 'db' && f.imsConfigKey).map((f) => f.imsConfigKey); + const imsByKey = Object.create(null); + if (imsKeys.length) { + try { + const ent = cds.entities(NS).ImsConfig; + if (ent) { + const rows = await SELECT.from(ent).columns('key', 'value').where({ key: { in: imsKeys } }); + for (const r of rows || []) imsByKey[r.key] = asStr(r.value); + } + } catch (err) { + LOG.warn('ImsConfig db-flag read failed', err.message); + } + } + return FEATURE_FLAGS.map((f) => { const base = { key: f.key, label: f.label, category: f.category, kind: f.kind, @@ -81,6 +98,18 @@ export async function resolveFeatureFlags() { }; } + // db (ImsConfig key/value): value is a 'true'/'false' string. No env + // layer — DB row wins, else the descriptor default. + if (f.kind === 'db') { + const raw = f.imsConfigKey in imsByKey ? imsByKey[f.imsConfigKey] : null; + const enabled = raw !== null ? String(raw).toLowerCase() === 'true' : Boolean(f.default); + return { + ...base, rawDbValue: raw, effectiveValue: String(enabled), enabled, + winningLayer: raw !== null ? 'db' : 'default', + howToChangeText: renderHowTo(f), + }; + } + // db-setting const row = rowByEntity[f.entity]; const rawDb = row ? asStr(row[f.column]) : null; @@ -137,5 +166,6 @@ function renderHowTo(f) { if (h.method === 'admin-tile') { return `Admin UI → ${h.tile} tile (${h.hash})${h.note ? ` — ${h.note}` : ''}`; } + if (h.text) return h.text; return ''; } diff --git a/srv/lib/freshness-detector.js b/srv/lib/freshness-detector.js index 0c086c2da..a5099202d 100644 --- a/srv/lib/freshness-detector.js +++ b/srv/lib/freshness-detector.js @@ -87,7 +87,7 @@ export const SYSTEM_PROMPT = [ 'You are a technical reviewer helping the AUTHOR of an SAP developer tutorial find code and dependency issues that would trip up a reader following the tutorial today.', 'You are given the tutorial\'s frontmatter and prerequisites, and, per code block, the prose immediately before and after it plus grounding context retrieved from official SAP docs.', 'Report obsolete dependencies, deprecated/superseded APIs, dated idioms, hardcoded secrets, and broken step flow.', - 'PRECISION: The author acts on every finding, so prefer reporting NOTHING over a speculative one. If you are not confident an issue would actually trip up a reader today, omit it. Report each distinct issue ONCE — do not repeat the same stale dependency across every block it appears in.', + 'PRECISION: The author acts on every finding, so calibrate by severity. For High and Medium severity, be strict — raise them only when you are confident the issue would actually trip up, mislead, or waste the time of a reader following the tutorial today; prefer omitting a High/Medium over reporting a speculative one. Low severity is ADVISORY and held to a lower bar: surface cosmetic or dated-style issues — an outdated idiom, a superseded-but-still-working pattern, or a hardcoded version/value a reader might copy verbatim — as confidence Low even when you are not certain they would break anything, as long as the issue is visible in the reader-authored code (not an output block) or supported by grounding. Report each distinct issue ONCE — do not repeat the same issue across every block it appears in. Do NOT default to reporting nothing: when the reader-authored code contains genuine Low-severity staleness, surface it as an advisory.', 'CONTEXT: Judge every block IN THE CONTEXT of its surrounding prose and the tutorial as a whole — never in isolation. The prerequisites define the reader\'s environment; for example a dev container in VS Code or GitHub Codespaces already provides a shell and the required toolchain, so do NOT flag setup the prerequisites already establish.', 'OUTPUT vs CODE: Many fenced blocks are illustrative OUTPUT — terminal or log output, HTTP responses, JSON payloads, directory trees, error messages — not code the reader writes. Never report staleness or secrets inside an output block.', 'RESPECT INTENT: Do NOT report something as a problem when the surrounding text shows it is intentional. Examples: an error or warning the tutorial deliberately triggers and then explains in the following paragraph; sample or illustrative credentials such as a base64-encoded demo user (e.g. "alice:") or obvious placeholder tokens. Only raise a hardcoded-secret finding when the value is a real, sensitive credential a reader would ship to production — never for demo values the tutorial is showing on purpose.', diff --git a/srv/lib/homepage/homepage-shelves-defaults.js b/srv/lib/homepage/homepage-shelves-defaults.js new file mode 100644 index 000000000..fbc824f93 --- /dev/null +++ b/srv/lib/homepage/homepage-shelves-defaults.js @@ -0,0 +1,1297 @@ +// srv/lib/homepage/homepage-shelves-defaults.js +// +// CANONICAL baseline for the HomepageShelves table (verb-page shelf entries + +// footer links). Single source of truth, seeded idempotently at boot by +// srv/lib/homepage/seed-homepage-shelves.js (called from cds.on('served')). +// +// Replaces the former test/data seed CSV + the manual db/data/staging +// third-party JSON + `npm run seed:thirdparty`. HomepageShelves is +// admin-managed on HANA (no .hdbtabledata ships in --production), so these +// rows are inserted ONLY when their (verb,url) is missing — admin edits to +// existing rows are never overwritten, and a deploy can never full-replace +// the table (that was the pre-#1404c4c4 data-loss bug). +// +// To add/curate a baseline link: edit this array (or use /admin-ui/#homepage +// at runtime). Rows: 97 across 7 verbs — {"LEARN":9,"BUILD":14,"INTEGRATE":14,"OPERATE":9,"AI":16,"CONNECT":16,"MODEL":19} +// +// GENERATED once from the retired CSV + staging JSON; now hand-maintained. + +export const HOMEPAGE_SHELVES_DEFAULTS = [ + { + "ID": "66333900-0001-0001-0001-000000000001", + "verb": "LEARN", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "Tutorial Navigator", + "url": "/tutorial-navigator/", + "isExternal": false, + "isActive": true, + "description": "Browse and filter 1 400+ hands-on tutorials across all SAP topics" + }, + { + "ID": "66333900-0001-0001-0001-000000000002", + "verb": "LEARN", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "SAP Learning Journeys", + "url": "https://learning.sap.com/learning-journeys", + "isExternal": true, + "isActive": true, + "description": "Curated multi-course paths toward certifications and role mastery" + }, + { + "ID": "66333900-0001-0001-0001-000000000003", + "verb": "LEARN", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "New to Cloud SAP?", + "url": "/missions/", + "isExternal": false, + "isActive": true, + "description": "Guided mission paths for developers moving from on-prem to cloud and AI" + }, + { + "ID": "66333900-0001-0001-0001-000000000004", + "verb": "LEARN", + "shelf": "REFERENCE", + "sortOrder": 40, + "title": "learning.sap.com", + "url": "https://learning.sap.com", + "isExternal": true, + "isActive": true, + "description": "Free and paid learning journeys, self-paced courses, and certification prep" + }, + { + "ID": "66333900-0001-0001-0001-000000000005", + "verb": "LEARN", + "shelf": "REFERENCE", + "sortOrder": 50, + "title": "SAP Help Portal", + "url": "https://help.sap.com", + "isExternal": true, + "isActive": true, + "description": "Centralized product documentation for all SAP products" + }, + { + "ID": "66333900-0001-0001-0001-000000000006", + "verb": "LEARN", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "Certifications", + "url": "https://www.sap.com/training-certification.html", + "isExternal": true, + "isActive": true, + "description": "Official SAP certification paths and instructor-led training" + }, + { + "ID": "66333900-0001-0001-0001-000000000007", + "verb": "LEARN", + "shelf": "TOOLS", + "sortOrder": 70, + "title": "BTP Free Tier Signup", + "url": "https://cockpit.btp.cloud.sap", + "isExternal": true, + "isActive": true, + "description": "Start building on SAP BTP with a free tier account — no credit card required" + }, + { + "ID": "66333900-0001-0001-0001-000000000008", + "verb": "LEARN", + "shelf": "TOOLS", + "sortOrder": 80, + "title": "SAP-samples on GitHub", + "url": "https://github.com/SAP-samples", + "isExternal": true, + "isActive": true, + "description": "Hundreds of sample apps, tutorials, and workshop repos from SAP" + }, + { + "ID": "66333900-0001-0001-0001-000000000009", + "verb": "LEARN", + "shelf": "KEEP_CURRENT", + "sortOrder": 90, + "title": "SAP Developer News", + "url": "https://youtube.com/@sapdevs", + "isExternal": true, + "isActive": true, + "description": "Weekly Friday show covering the latest developer news, tutorials, and events" + }, + { + "ID": "66333900-0001-0002-0001-000000000001", + "verb": "BUILD", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "SAP CAP", + "url": "https://cap.cloud.sap", + "isExternal": true, + "isActive": true, + "description": "Official docs for the Cloud Application Programming Model — Node.js + Java" + }, + { + "ID": "66333900-0001-0002-0001-000000000002", + "verb": "BUILD", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "ABAP Cloud + RAP", + "url": "https://help.sap.com/docs/abap-cloud", + "isExternal": true, + "isActive": true, + "description": "Clean-core ABAP development with RAP, ADT, and the BTP ABAP environment" + }, + { + "ID": "66333900-0001-0002-0001-000000000003", + "verb": "BUILD", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "Fiori / UI5", + "url": "https://ui5.sap.com", + "isExternal": true, + "isActive": true, + "description": "SAP UI5 Demo Kit — API reference, samples, and interactive demos" + }, + { + "ID": "66333900-0001-0002-0001-000000000004", + "verb": "BUILD", + "shelf": "START_HERE", + "sortOrder": 40, + "title": "SAP Build", + "url": "https://www.sap.com/products/technology-platform/build.html", + "isExternal": true, + "isActive": true, + "description": "Low-code app, automation, and website suite on BTP for faster delivery" + }, + { + "ID": "66333900-0001-0002-0001-000000000005", + "verb": "BUILD", + "shelf": "START_HERE", + "sortOrder": 50, + "title": "SAP Build Code", + "url": "https://www.sap.com/products/technology-platform/build-code.html", + "isExternal": true, + "isActive": true, + "description": "AI-assisted pro-code development tooling on BTP with Joule co-pilot" + }, + { + "ID": "66333900-0001-0002-0001-000000000006", + "verb": "BUILD", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "SAP Cloud SDK", + "url": "https://sap.github.io/cloud-sdk/", + "isExternal": true, + "isActive": true, + "description": "Java + JavaScript SDKs for consuming SAP APIs from any cloud app" + }, + { + "ID": "66333900-0001-0002-0001-000000000007", + "verb": "BUILD", + "shelf": "REFERENCE", + "sortOrder": 70, + "title": "Fiori Design System", + "url": "https://www.sap.com/design-system/", + "isExternal": true, + "isActive": true, + "description": "SAP Fiori design guidelines for web, mobile, and Build apps" + }, + { + "ID": "66333900-0001-0002-0001-000000000008", + "verb": "BUILD", + "shelf": "REFERENCE", + "sortOrder": 80, + "title": "Business Application Studio", + "url": "https://help.sap.com/docs/SAP_BUSINESS_APPLICATION_STUDIO", + "isExternal": true, + "isActive": true, + "description": "Official docs for the cloud IDE purpose-built for SAP development" + }, + { + "ID": "66333900-0001-0002-0001-000000000009", + "verb": "BUILD", + "shelf": "TOOLS", + "sortOrder": 90, + "title": "SAP Development Tools", + "url": "https://tools.hana.ondemand.com", + "isExternal": true, + "isActive": true, + "description": "Download portal for Eclipse plugins, CLIs, HANA client, and SDKs" + }, + { + "ID": "66333900-0001-0002-0001-000000000010", + "verb": "BUILD", + "shelf": "TOOLS", + "sortOrder": 100, + "title": "UI5 Web Components for React", + "url": "https://sap.github.io/ui5-webcomponents-react/", + "isExternal": true, + "isActive": true, + "description": "React wrappers for UI5 web components — enterprise-grade UI in React" + }, + { + "ID": "66333900-0001-0002-0001-000000000011", + "verb": "BUILD", + "shelf": "KEEP_CURRENT", + "sortOrder": 110, + "title": "SAP Tech Bytes", + "url": "https://youtube.com/@sapdevs", + "isExternal": true, + "isActive": true, + "description": "Short-form, code-focused SAP Developers YouTube videos" + }, + { + "ID": "66333900-0001-0002-0001-000000000012", + "verb": "BUILD", + "shelf": "KEEP_CURRENT", + "sortOrder": 120, + "title": "CAP Community Blogs", + "url": "https://community.sap.com/t5/all-sap-community-blogs/ct-p/all-blogs", + "isExternal": true, + "isActive": true, + "description": "Latest community articles on CAP, ABAP, Fiori, and BTP development" + }, + { + "ID": "66333900-0001-0002-0001-000000000013", + "verb": "BUILD", + "shelf": "KEEP_CURRENT", + "sortOrder": 130, + "title": "CodeJams", + "url": "/tutorial-navigator/", + "isExternal": false, + "isActive": true, + "description": "Hands-on in-person and virtual workshops delivered by SAP experts" + }, + { + "ID": "66333900-0001-0003-0001-000000000001", + "verb": "INTEGRATE", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "SAP Business Accelerator Hub", + "url": "https://api.sap.com", + "isExternal": true, + "isActive": true, + "description": "Browse and test SAP APIs across S/4HANA, SuccessFactors, Ariba, and more" + }, + { + "ID": "66333900-0001-0003-0001-000000000002", + "verb": "INTEGRATE", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "SAP Integration Suite", + "url": "https://help.sap.com/docs/integration-suite", + "isExternal": true, + "isActive": true, + "description": "Docs for Cloud Integration, API Management, and Event Mesh on BTP" + }, + { + "ID": "66333900-0001-0003-0001-000000000003", + "verb": "INTEGRATE", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "Your First Integration Flow", + "url": "/tutorial-navigator/", + "isExternal": false, + "isActive": true, + "description": "Step-by-step tutorials for building your first integration on SAP BTP" + }, + { + "ID": "66333900-0001-0003-0001-000000000004", + "verb": "INTEGRATE", + "shelf": "REFERENCE", + "sortOrder": 40, + "title": "SAP Event Mesh", + "url": "https://help.sap.com/docs/event-mesh", + "isExternal": true, + "isActive": true, + "description": "Managed message broker for event-driven architectures on BTP" + }, + { + "ID": "66333900-0001-0003-0001-000000000005", + "verb": "INTEGRATE", + "shelf": "REFERENCE", + "sortOrder": 50, + "title": "SAP Destination Service", + "url": "https://help.sap.com/docs/destination-service", + "isExternal": true, + "isActive": true, + "description": "BTP service that brokers credentials to external endpoints securely" + }, + { + "ID": "66333900-0001-0003-0001-000000000006", + "verb": "INTEGRATE", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "Open Resource Discovery", + "url": "https://open-resource-discovery.org", + "isExternal": true, + "isActive": true, + "description": "Open protocol for publishing and discovering application + service metadata" + }, + { + "ID": "66333900-0001-0003-0001-000000000007", + "verb": "INTEGRATE", + "shelf": "REFERENCE", + "sortOrder": 70, + "title": "SAP OData Vocabularies", + "url": "https://sap.github.io/odata-vocabularies/", + "isExternal": true, + "isActive": true, + "description": "OData annotation term definitions across SAP business domains" + }, + { + "ID": "66333900-0001-0003-0001-000000000008", + "verb": "INTEGRATE", + "shelf": "TOOLS", + "sortOrder": 80, + "title": "Project Piper", + "url": "https://www.project-piper.io/", + "isExternal": true, + "isActive": true, + "description": "Pre-built CI/CD pipelines and shared library for SAP application delivery" + }, + { + "ID": "66333900-0001-0003-0001-000000000009", + "verb": "INTEGRATE", + "shelf": "TOOLS", + "sortOrder": 90, + "title": "Integration Samples", + "url": "https://github.com/SAP-samples", + "isExternal": true, + "isActive": true, + "description": "Integration flow samples, API patterns, and event-driven architecture demos" + }, + { + "ID": "66333900-0001-0004-0001-000000000001", + "verb": "OPERATE", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "SAP BTP Cockpit", + "url": "https://cockpit.btp.cloud.sap", + "isExternal": true, + "isActive": true, + "description": "Browser console for managing BTP global accounts, subaccounts, and spaces" + }, + { + "ID": "66333900-0001-0004-0001-000000000002", + "verb": "OPERATE", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "BTP CLI", + "url": "https://help.sap.com/docs/btp-cli", + "isExternal": true, + "isActive": true, + "description": "Command-line reference for the `btp` tool — automate BTP account management" + }, + { + "ID": "66333900-0001-0004-0001-000000000003", + "verb": "OPERATE", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "BTP Getting Started", + "url": "https://help.sap.com/docs/btp/sap-business-technology-platform/getting-started", + "isExternal": true, + "isActive": true, + "description": "Onboarding entry point and checklist for SAP Business Technology Platform" + }, + { + "ID": "66333900-0001-0004-0001-000000000004", + "verb": "OPERATE", + "shelf": "REFERENCE", + "sortOrder": 40, + "title": "SAP BTP Documentation", + "url": "https://help.sap.com/docs/btp", + "isExternal": true, + "isActive": true, + "description": "Canonical reference docs for SAP Business Technology Platform" + }, + { + "ID": "66333900-0001-0004-0001-000000000005", + "verb": "OPERATE", + "shelf": "REFERENCE", + "sortOrder": 50, + "title": "SAP Discovery Center", + "url": "https://discovery-center.cloud.sap", + "isExternal": true, + "isActive": true, + "description": "BTP service catalog, guided missions, estimator, and pricing" + }, + { + "ID": "66333900-0001-0004-0001-000000000006", + "verb": "OPERATE", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "Kyma", + "url": "https://kyma-project.io", + "isExternal": true, + "isActive": true, + "description": "Open-source Kubernetes platform for cloud-native BTP extensions" + }, + { + "ID": "66333900-0001-0004-0001-000000000007", + "verb": "OPERATE", + "shelf": "REFERENCE", + "sortOrder": 70, + "title": "SAP HANA Cloud", + "url": "https://help.sap.com/docs/SAP_HANA_CLOUD", + "isExternal": true, + "isActive": true, + "description": "Canonical docs for HANA Cloud in-memory database with vector engine" + }, + { + "ID": "66333900-0001-0004-0001-000000000008", + "verb": "OPERATE", + "shelf": "TOOLS", + "sortOrder": 80, + "title": "Business Application Studio", + "url": "https://help.sap.com/docs/SAP_BUSINESS_APPLICATION_STUDIO", + "isExternal": true, + "isActive": true, + "description": "Cloud IDE purpose-built for CAP, Fiori, mobile, and full-stack BTP development" + }, + { + "ID": "66333900-0001-0004-0001-000000000009", + "verb": "OPERATE", + "shelf": "TOOLS", + "sortOrder": 90, + "title": "SAP Development Tools", + "url": "https://tools.hana.ondemand.com", + "isExternal": true, + "isActive": true, + "description": "Download Eclipse plugins, CLIs, HANA client, and SDKs" + }, + { + "ID": "66333900-0001-0005-0001-000000000001", + "verb": "AI", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "BTP AI Best Practices", + "url": "https://btp-ai-bp.docs.sap/", + "isExternal": true, + "isActive": true, + "description": "Curated guides and architecture patterns for building AI apps on BTP" + }, + { + "ID": "66333900-0001-0005-0001-000000000002", + "verb": "AI", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "AI Skills Library", + "url": "https://skills.cloud.sap/", + "isExternal": true, + "isActive": true, + "description": "Searchable catalog of certified AI skills built on SAP BTP" + }, + { + "ID": "66333900-0001-0005-0001-000000000009", + "verb": "AI", + "shelf": "START_HERE", + "sortOrder": 25, + "title": "Joule Studio", + "url": "https://help.sap.com/docs/joule-studio", + "isExternal": true, + "isActive": true, + "description": "Low-code authoring environment for custom Joule skills, prompts, and tool integrations — next-generation edition (Q3 2026)", + "badge": "NEW" + }, + { + "ID": "66333900-0001-0005-0001-000000000003", + "verb": "AI", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "Your First Joule Extension", + "url": "/tutorial-navigator/", + "isExternal": false, + "isActive": true, + "description": "Tutorial series for building custom Joule extensions with the AI SDK" + }, + { + "ID": "66333900-0001-0005-0001-000000000004", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 40, + "title": "SAP Joule", + "url": "https://help.sap.com/docs/joule", + "isExternal": true, + "isActive": true, + "description": "Docs for SAP's generative-AI copilot embedded across SAP products" + }, + { + "ID": "66333900-0001-0005-0001-000000000010", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 45, + "title": "Joule Studio (Classic)", + "url": "https://help.sap.com/docs/joule-studio#classic", + "isExternal": true, + "isActive": true, + "description": "Classic Joule Studio edition — GA since December 2025 — for teams already shipping skills today" + }, + { + "ID": "66333900-0001-0005-0001-000000000005", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 50, + "title": "SAP AI Core", + "url": "https://help.sap.com/docs/sap-ai-core", + "isExternal": true, + "isActive": true, + "description": "Runtime for training and serving ML + GenAI models on BTP" + }, + { + "ID": "66333900-0001-0005-0001-000000000006", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "SAP AI Launchpad", + "url": "https://help.sap.com/docs/sap-ai-launchpad", + "isExternal": true, + "isActive": true, + "description": "Web UI to manage AI scenarios, models, and deployments on BTP" + }, + { + "ID": "66333900-0001-0005-0001-000000000007", + "verb": "AI", + "shelf": "TOOLS", + "sortOrder": 70, + "title": "AI4U Use Case Repository", + "url": "https://ai4u-website.cfapps.eu10-004.hana.ondemand.com", + "isExternal": true, + "isActive": true, + "description": "50+ real-world AI projects built on SAP BTP — searchable by scenario" + }, + { + "ID": "66333900-0001-0005-0001-000000000008", + "verb": "AI", + "shelf": "TOOLS", + "sortOrder": 80, + "title": "RAG on HANA Cookbook", + "url": "https://github.com/SAP-samples", + "isExternal": true, + "isActive": true, + "description": "Retrieval-augmented generation patterns using HANA Cloud vector engine" + }, + { + "ID": "66333900-0001-0006-0001-000000000001", + "verb": "CONNECT", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "SAP Community", + "url": "https://community.sap.com", + "isExternal": true, + "isActive": true, + "description": "Networking, Q&A, blogs, and groups for all SAP developer roles" + }, + { + "ID": "66333900-0001-0006-0001-000000000002", + "verb": "CONNECT", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "SAP Developers YouTube", + "url": "https://youtube.com/@sapdevs", + "isExternal": true, + "isActive": true, + "description": "Tutorial videos, Tech Bytes, Developer News, and live streams" + }, + { + "ID": "66333900-0001-0006-0001-000000000003", + "verb": "CONNECT", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "Devtoberfest", + "url": "https://community.sap.com/t5/devtoberfest/gh-p/Devtoberfest", + "isExternal": true, + "isActive": true, + "description": "Annual open developer celebration with sessions, challenges, and prizes" + }, + { + "ID": "66333900-0001-0006-0001-000000000004", + "verb": "CONNECT", + "shelf": "REFERENCE", + "sortOrder": 40, + "title": "SAP News Center", + "url": "https://news.sap.com", + "isExternal": true, + "isActive": true, + "description": "Official corporate news, customer stories, and product announcements" + }, + { + "ID": "66333900-0001-0006-0001-000000000005", + "verb": "CONNECT", + "shelf": "REFERENCE", + "sortOrder": 50, + "title": "Community Blogs", + "url": "https://community.sap.com/t5/all-sap-community-blogs/ct-p/all-blogs", + "isExternal": true, + "isActive": true, + "description": "Aggregated blog stream from the SAP Community" + }, + { + "ID": "66333900-0001-0006-0001-000000000006", + "verb": "CONNECT", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "Developer Advocates", + "url": "/developer-advocates/", + "isExternal": false, + "isActive": true, + "description": "Meet the SAP Developer Advocate team and find their content" + }, + { + "ID": "66333900-0001-0006-0001-000000000007", + "verb": "CONNECT", + "shelf": "TOOLS", + "sortOrder": 70, + "title": "SAP on GitHub", + "url": "https://github.com/SAP", + "isExternal": true, + "isActive": true, + "description": "SAP's open-source projects and repositories on GitHub" + }, + { + "ID": "66333900-0001-0006-0001-000000000008", + "verb": "CONNECT", + "shelf": "TOOLS", + "sortOrder": 80, + "title": "SAP-samples on GitHub", + "url": "https://github.com/SAP-samples", + "isExternal": true, + "isActive": true, + "description": "Sample code, tutorial repos, and workshop materials from SAP" + }, + { + "ID": "66333900-0001-0006-0001-000000000009", + "verb": "CONNECT", + "shelf": "TOOLS", + "sortOrder": 90, + "title": "SAP-docs on GitHub", + "url": "https://github.com/SAP-docs", + "isExternal": true, + "isActive": true, + "description": "Markdown sources for BTP/UI5/ABAP docs — public PRs welcome", + "badge": "HIDDEN_GEM" + }, + { + "ID": "66333900-0001-0006-0001-000000000010", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 100, + "title": "SAP TechEd", + "url": "https://www.sap.com/about/events/sap-teched.html", + "isExternal": true, + "isActive": true, + "description": "Annual developer conference with sessions, workshops, and hands-on labs" + }, + { + "ID": "66333900-0001-0006-0001-000000000011", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 110, + "title": "ASUG", + "url": "https://www.asug.com", + "isExternal": true, + "isActive": true, + "description": "North America's largest SAP user community — events and networking", + "badge": "THIRD_PARTY" + }, + { + "ID": "66333900-0001-0006-0001-000000000012", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 120, + "title": "SAPinsider", + "url": "https://sapinsider.org", + "isExternal": true, + "isActive": true, + "description": "Independent SAP professional community publication and events", + "badge": "THIRD_PARTY" + }, + { + "ID": "66333900-1029-1001-0001-000000000001", + "verb": "MODEL", + "shelf": "START_HERE", + "sortOrder": 10, + "title": "SAP HANA Cloud", + "url": "https://help.sap.com/docs/hana-cloud", + "isExternal": true, + "isActive": true, + "description": "Managed in-memory database with columnar analytics, spatial, and graph capabilities" + }, + { + "ID": "66333900-1029-1001-0001-000000000002", + "verb": "MODEL", + "shelf": "START_HERE", + "sortOrder": 20, + "title": "SAP Datasphere", + "url": "https://help.sap.com/docs/SAP_DATASPHERE", + "isExternal": true, + "isActive": true, + "description": "Business data fabric — model, federate, and govern data across SAP and third-party sources" + }, + { + "ID": "66333900-1029-1001-0001-000000000003", + "verb": "MODEL", + "shelf": "START_HERE", + "sortOrder": 30, + "title": "SAP Business Data Cloud", + "url": "https://www.sap.com/products/data-cloud/business-data-cloud.html", + "isExternal": true, + "isActive": true, + "description": "Unified data platform that combines SAP + Databricks for AI-ready business data", + "badge": "NEW" + }, + { + "ID": "66333900-1029-1002-0001-000000000001", + "verb": "MODEL", + "shelf": "REFERENCE", + "sortOrder": 40, + "title": "HANA Cloud Modeling Guide", + "url": "https://help.sap.com/docs/hana-cloud/sap-hana-cloud-modeling-guide/sap-hana-cloud-modeling-guide", + "isExternal": true, + "isActive": true, + "description": "Reference for calculation views, HDI containers, and semantic modeling in HANA Cloud" + }, + { + "ID": "66333900-1029-1002-0001-000000000002", + "verb": "MODEL", + "shelf": "REFERENCE", + "sortOrder": 50, + "title": "Datasphere Modeling", + "url": "https://help.sap.com/docs/SAP_DATASPHERE/9f804b8efa8043539289f42f372c4862/459f2ecb37fd4c0299edac35e1670ac6.html", + "isExternal": true, + "isActive": true, + "description": "Build semantic views, data flows, and analytic models in Datasphere spaces" + }, + { + "ID": "66333900-1029-1002-0001-000000000003", + "verb": "MODEL", + "shelf": "REFERENCE", + "sortOrder": 60, + "title": "SAP Analytics Cloud", + "url": "https://help.sap.com/docs/SAP_ANALYTICS_CLOUD", + "isExternal": true, + "isActive": true, + "description": "Cloud analytics — stories, dashboards, planning, and predictive on top of your data models" + }, + { + "ID": "66333900-1029-1003-0001-000000000001", + "verb": "MODEL", + "shelf": "TOOLS", + "sortOrder": 70, + "title": "SAP HANA Database Explorer", + "url": "https://help.sap.com/docs/hana-cloud-database/sap-hana-database-explorer/sap-hana-database-explorer", + "isExternal": true, + "isActive": true, + "description": "Web-based SQL console, catalog browser, and modeling tool for HANA Cloud" + }, + { + "ID": "66333900-1029-1003-0001-000000000002", + "verb": "MODEL", + "shelf": "TOOLS", + "sortOrder": 80, + "title": "hana-cli", + "url": "https://www.npmjs.com/package/hana-cli", + "isExternal": true, + "isActive": true, + "description": "Command-line toolkit for HANA — SQL, table inspection, data profiling, and imports" + }, + { + "ID": "66333900-1029-1003-0001-000000000003", + "verb": "MODEL", + "shelf": "TOOLS", + "sortOrder": 90, + "title": "Data Model Samples", + "url": "https://github.com/SAP-samples?q=hana&type=all", + "isExternal": true, + "isActive": true, + "description": "Canonical HANA, Datasphere, and analytics data-model examples on SAP-samples" + }, + { + "ID": "66333900-1029-1004-0001-000000000001", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 100, + "title": "HANA Cloud What's New", + "url": "https://help.sap.com/docs/hana-cloud/sap-hana-cloud-what-s-new/sap-hana-cloud-what-s-new", + "isExternal": true, + "isActive": true, + "description": "Release notes for HANA Cloud — new features and quarterly capability additions" + }, + { + "ID": "66333900-1029-1004-0001-000000000002", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 110, + "title": "Datasphere Roadmap", + "url": "https://roadmaps.sap.com/board?PRODUCT=73555000100800002141", + "isExternal": true, + "isActive": true, + "description": "Public roadmap for SAP Datasphere — planned innovations and delivery timelines" + }, + { + "ID": "66333900-1029-1004-0001-000000000003", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 120, + "title": "Data & Analytics Community", + "url": "https://community.sap.com/t5/technology/ct-p/technology-blogs", + "isExternal": true, + "isActive": true, + "description": "SAP Community blogs and Q&A for HANA, Datasphere, BDC, and SAC practitioners" + }, + { + "ID": "66333900-rpt1-0005-0001-000000000001", + "verb": "AI", + "shelf": "START_HERE", + "sortOrder": 26, + "title": "SAP RPT-1 Playground", + "url": "https://rpt.cloud.sap", + "description": "Try SAP's RPT-1 tabular foundation model in the browser — bring your own data or use SAP-provided example datasets.", + "badge": "NEW", + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Predict on your tables with RPT-1 — no training, right in the browser.", + "whyItMatters": "RPT-1 is SAP's semantics-aware foundation model for tabular data. The playground lets you run classification and regression on your own tables or SAP sample datasets without any setup — the fastest way to see what the model does.", + "personaTags": [ + "role:developer", + "role:student", + "deployment:cloud" + ] + }, + { + "ID": "66333900-rpt1-0005-0001-000000000002", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 35, + "title": "SAP RPT-1 on Hugging Face", + "url": "https://huggingface.co/SAP/sap-rpt-1-oss", + "description": "Open-source release of SAP RPT-1 — model card, weights, and usage examples for the tabular foundation model.", + "badge": "NEW", + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "The open-source RPT-1 model card, weights, and examples.", + "whyItMatters": "RPT-1 is published open-source on Hugging Face as a semantics-aware tabular in-context learner for classification and regression. The model card is the reference for developers integrating it into their own pipelines.", + "personaTags": [ + "role:developer", + "role:student", + "deployment:cloud" + ] + }, + { + "ID": "66333900-rpt1-1029-0001-000000000001", + "verb": "MODEL", + "shelf": "REFERENCE", + "sortOrder": 65, + "title": "SAP RPT-1", + "url": "https://rpt.cloud.sap", + "description": "SAP's relational/tabular foundation model — predict directly on structured data without task-specific training.", + "badge": "NEW", + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "A foundation model that predicts directly on your data models.", + "whyItMatters": "RPT-1 brings foundation-model prediction to structured, tabular data — the shape of most SAP business data. For teams modeling data in HANA Cloud or Datasphere, it is a fast path to classification and regression without building a bespoke ML pipeline.", + "personaTags": [ + "role:developer", + "role:architect", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-0002-0001-000000000001", + "verb": "BUILD", + "shelf": "TOOLS", + "sortOrder": 200, + "title": "Vercel", + "url": "https://vercel.com", + "description": "Frontend cloud for building and deploying React, Vue, and static sites with global edge delivery.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Deploy React and Vue frontends to a global edge in minutes.", + "whyItMatters": "CAP treats React and Vue as first-class frontends. Vercel is a common host for those SPAs, giving developers preview deployments, edge functions, and CI-driven releases that pair well with a CAP backend on BTP.", + "personaTags": [ + "role:developer", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-1029-0001-000000000001", + "verb": "MODEL", + "shelf": "REFERENCE", + "sortOrder": 200, + "title": "Dremio", + "url": "https://www.dremio.com", + "description": "Lakehouse platform for SQL analytics directly on data lake storage, built around Apache Iceberg.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Query your data lake with SQL — no copies, no cubes.", + "whyItMatters": "Dremio is a leading open lakehouse engine. For teams federating SAP data with lake storage, it is a practical reference for Iceberg-based analytics alongside SAP Datasphere and Business Data Cloud.", + "personaTags": [ + "role:developer", + "role:architect", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-1029-0001-000000000002", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 210, + "title": "Dremio Community", + "url": "https://community.dremio.com", + "description": "Community forum for Dremio users — Q&A, how-tos, and release discussion.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Ask questions and share patterns with other Dremio users.", + "whyItMatters": "The Dremio community forum is where lakehouse practitioners troubleshoot Iceberg, reflections, and federation — useful when integrating lake data with SAP analytics.", + "personaTags": [ + "role:developer", + "role:architect" + ] + }, + { + "ID": "66333900-3rd0-1029-0001-000000000003", + "verb": "MODEL", + "shelf": "REFERENCE", + "sortOrder": 220, + "title": "Apache Iceberg", + "url": "https://iceberg.apache.org", + "description": "Open table format for huge analytic datasets — the storage standard behind modern lakehouses.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "The open table format underpinning modern lakehouses.", + "whyItMatters": "Iceberg is the table format SAP Business Data Cloud and many lake engines build on. Understanding it helps architects reason about how SAP and non-SAP data interoperate at the storage layer.", + "personaTags": [ + "role:developer", + "role:architect", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-1029-0001-000000000004", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 230, + "title": "Data Engineering Weekly", + "url": "https://www.dataengineeringweekly.com", + "description": "Curated weekly newsletter on data engineering trends, tools, and architecture.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Stay current on the wider data-engineering ecosystem.", + "whyItMatters": "A concise weekly read that keeps data architects aware of trends beyond the SAP stack — pipelines, formats, and platform shifts that influence integration choices.", + "personaTags": [ + "role:architect" + ] + }, + { + "ID": "66333900-3rd0-1029-0001-000000000005", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 240, + "title": "The Data Stack Show", + "url": "https://datastackshow.com", + "description": "Podcast with data engineers and founders on how modern data stacks are built and run.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Conversations on how real data stacks get built.", + "whyItMatters": "Practitioner interviews that surface the tradeoffs behind modern data platforms — helpful context for teams positioning SAP data products within a broader stack.", + "personaTags": [ + "role:developer", + "role:architect" + ] + }, + { + "ID": "66333900-3rd0-1029-0001-000000000006", + "verb": "MODEL", + "shelf": "KEEP_CURRENT", + "sortOrder": 250, + "title": "Reltio Community", + "url": "https://community.reltio.com", + "description": "Community for Reltio master-data-management practitioners — Q&A and best practices.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Master-data-management practices from the Reltio community.", + "whyItMatters": "MDM is a frequent companion to SAP data landscapes. The Reltio community is a reference point for entity resolution and data-quality patterns that complement SAP master data.", + "personaTags": [ + "role:developer", + "role:architect" + ] + }, + { + "ID": "66333900-3rd0-0005-0001-000000000001", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 200, + "title": "Hugging Face", + "url": "https://huggingface.co", + "description": "Hub for open models, datasets, and ML tooling — the de facto registry for the AI ecosystem.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "The open hub for models, datasets, and ML tooling.", + "whyItMatters": "Hugging Face is where most open models and datasets live. Developers building AI on BTP often source or evaluate models here before deploying via SAP AI Core.", + "personaTags": [ + "role:developer", + "role:student", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-0005-0001-000000000002", + "verb": "AI", + "shelf": "TOOLS", + "sortOrder": 210, + "title": "TabPFN (Prior Labs)", + "url": "https://github.com/PriorLabs/TabPFN", + "description": "Foundation model for tabular data that delivers strong results on small datasets without training.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "A foundation model for tabular data — no training required.", + "whyItMatters": "Most enterprise data is tabular. TabPFN is a notable open model for tabular prediction, relevant to developers exploring ML on structured SAP data.", + "personaTags": [ + "role:developer", + "role:student" + ] + }, + { + "ID": "66333900-3rd0-0005-0001-000000000003", + "verb": "AI", + "shelf": "REFERENCE", + "sortOrder": 220, + "title": "Prior Labs Research", + "url": "https://priorlabs.ai/research", + "description": "Research and technical reports behind TabPFN and tabular foundation models.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "The research behind tabular foundation models.", + "whyItMatters": "Prior Labs publishes the papers and reports underpinning TabPFN, including work featured in Nature — a primary source for developers evaluating the approach.", + "personaTags": [ + "role:developer", + "role:student" + ] + }, + { + "ID": "66333900-3rd0-0005-0001-000000000004", + "verb": "AI", + "shelf": "TOOLS", + "sortOrder": 230, + "title": "Kaggle", + "url": "https://www.kaggle.com", + "description": "Platform for datasets, notebooks, and ML competitions with a large practitioner community.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Datasets, notebooks, and competitions to sharpen ML skills.", + "whyItMatters": "Kaggle is a practical training ground for data science. Its datasets and notebooks help developers and students build the ML skills they later apply on SAP data.", + "personaTags": [ + "role:developer", + "role:student" + ] + }, + { + "ID": "66333900-3rd0-0003-0001-000000000001", + "verb": "INTEGRATE", + "shelf": "KEEP_CURRENT", + "sortOrder": 200, + "title": "n8n Community Forum", + "url": "https://community.n8n.io", + "description": "Official forum for the n8n workflow-automation community — Q&A, templates, and help.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Get help and share n8n automation workflows.", + "whyItMatters": "n8n is a popular open workflow-automation tool used to integrate SAP and non-SAP systems. Its forum is the primary place to find node patterns and troubleshoot flows.", + "personaTags": [ + "role:developer", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-0003-0001-000000000002", + "verb": "INTEGRATE", + "shelf": "KEEP_CURRENT", + "sortOrder": 210, + "title": "n8n Discord", + "url": "https://discord.gg/n8n", + "description": "Real-time chat community for n8n users and contributors.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Real-time chat with the n8n community.", + "whyItMatters": "The n8n Discord is where users get quick answers and share in-progress automations — a fast channel when building integrations that touch SAP endpoints.", + "personaTags": [ + "role:developer" + ] + }, + { + "ID": "66333900-3rd0-0003-0001-000000000003", + "verb": "INTEGRATE", + "shelf": "KEEP_CURRENT", + "sortOrder": 220, + "title": "n8n on YouTube", + "url": "https://www.youtube.com/c/n8n-io", + "description": "Official n8n channel — tutorials, feature demos, and automation walkthroughs.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Video tutorials and demos for n8n automation.", + "whyItMatters": "n8n's YouTube channel walks through building automations step by step — a fast way to learn node patterns before wiring up SAP integrations.", + "personaTags": [ + "role:developer" + ] + }, + { + "ID": "66333900-3rd0-0003-0001-000000000004", + "verb": "INTEGRATE", + "shelf": "TOOLS", + "sortOrder": 230, + "title": "n8n (n8n-io/n8n)", + "url": "https://github.com/n8n-io/n8n", + "description": "Source repository for the n8n workflow-automation platform.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "The open-source n8n automation engine on GitHub.", + "whyItMatters": "The n8n repo is the source of truth for the automation engine and its nodes — the place to file issues, read code, and understand how integrations execute.", + "personaTags": [ + "role:developer", + "deployment:cloud" + ] + }, + { + "ID": "66333900-3rd0-0003-0001-000000000005", + "verb": "INTEGRATE", + "shelf": "TOOLS", + "sortOrder": 240, + "title": "n8n docs (n8n-io/n8n-docs)", + "url": "https://github.com/n8n-io/n8n-docs", + "description": "Documentation source for n8n — node references and self-hosting guides.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Docs and node references for n8n, open to contributions.", + "whyItMatters": "The n8n-docs repo holds node references and self-hosting guides, and accepts public PRs — useful when documenting a custom SAP integration node.", + "personaTags": [ + "role:developer" + ] + }, + { + "ID": "66333900-3rd0-0006-0001-000000000001", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 200, + "title": "r/SAP", + "url": "https://www.reddit.com/r/SAP", + "description": "Reddit community discussing SAP products, careers, and day-to-day practice.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Candid SAP discussion from the wider practitioner community.", + "whyItMatters": "r/SAP is an unfiltered view of what SAP practitioners are dealing with — a useful pulse-check beyond official channels.", + "personaTags": [ + "role:developer" + ] + }, + { + "ID": "66333900-3rd0-0006-0001-000000000002", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 210, + "title": "r/dataengineering", + "url": "https://www.reddit.com/r/dataengineering", + "description": "Reddit community for data engineers — tooling debates, career, and architecture.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Where data engineers debate tools and architecture.", + "whyItMatters": "A high-signal community for data-platform trends and tradeoffs that inform how SAP data fits a broader engineering stack.", + "personaTags": [ + "role:architect" + ] + }, + { + "ID": "66333900-3rd0-0006-0001-000000000003", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 220, + "title": "r/MachineLearning", + "url": "https://www.reddit.com/r/MachineLearning", + "description": "Reddit community covering ML research, tooling, and applied practice.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "ML research and applied practice, community-curated.", + "whyItMatters": "Keeps developers aware of ML advances they may bring to SAP data — from model families to applied techniques.", + "personaTags": [ + "role:developer" + ] + }, + { + "ID": "66333900-3rd0-0006-0001-000000000004", + "verb": "CONNECT", + "shelf": "KEEP_CURRENT", + "sortOrder": 230, + "title": "r/n8n", + "url": "https://www.reddit.com/r/n8n", + "description": "Reddit community for n8n workflow automation — recipes and troubleshooting.", + "badge": null, + "isExternal": true, + "isActive": true, + "authoringStatus": "REVIEWED", + "personaWeight": 0, + "tagline": "Automation recipes and troubleshooting for n8n.", + "whyItMatters": "A community source for n8n automation recipes, complementing the official forum when building integrations across SAP and other systems.", + "personaTags": [ + "role:developer" + ] + } +]; diff --git a/srv/lib/homepage/seed-homepage-shelves.js b/srv/lib/homepage/seed-homepage-shelves.js new file mode 100644 index 000000000..8e5e7f7c7 --- /dev/null +++ b/srv/lib/homepage/seed-homepage-shelves.js @@ -0,0 +1,45 @@ +// srv/lib/homepage/seed-homepage-shelves.js +// +// Idempotent, NON-DESTRUCTIVE seed for the HomepageShelves table. +// +// Runs from cds.on('served') (srv/server.js) on every boot, in every profile +// (dev cds watch, unit tests via cds.test, hybrid, production). Inserts ONLY +// the baseline (verb,url) rows that are missing — it never updates or deletes +// existing rows, so admin edits made at /admin-ui/#homepage are preserved and a +// deploy can never full-replace the table. +// +// This replaces two fragile mechanisms: +// 1. the test/data seed CSV, which compiled to an .hdbtabledata with +// include_filter:[] and full-replaced the table on every deploy whose CSV +// hash changed (the pre-#1404c4c4 data-loss bug), and +// 2. the manual, DEV/PROD-ambiguous `npm run seed:thirdparty` promotion step. +// +// Single source of truth: ./homepage-shelves-defaults.js. + +import cds from '@sap/cds'; +import { HOMEPAGE_SHELVES_DEFAULTS } from './homepage-shelves-defaults.js'; + +const NAMESPACE = 'com.sap.developers.ims'; + +export async function seedHomepageShelves(dbOverride) { + const db = dbOverride ?? (await cds.connect.to('db')); + // Reflect the entity via cds.linked so CQL is type-aware and serializes the + // personaTags/personaHidden arrays to JSON for the HANA NCLOB columns. + // (cds.entities is undefined in a standalone context; a fully-qualified + // string name is not type-aware and fails on HANA for the array columns.) + const linked = cds.linked(cds.model ?? (await cds.load('*'))); + const { HomepageShelves } = linked.entities(NAMESPACE); + + const existing = await db.run( + SELECT.from(HomepageShelves).columns('verb', 'url'), + ); + const have = new Set(existing.map((r) => `${r.verb}|${r.url}`)); + const missing = HOMEPAGE_SHELVES_DEFAULTS.filter( + (r) => !have.has(`${r.verb}|${r.url}`), + ); + + if (missing.length > 0) { + await db.run(INSERT.into(HomepageShelves).entries(missing)); + } + return { inserted: missing.length, total: HOMEPAGE_SHELVES_DEFAULTS.length }; +} diff --git a/srv/lib/kg-path.js b/srv/lib/kg-path.js index d422c1753..91f2fc155 100644 --- a/srv/lib/kg-path.js +++ b/srv/lib/kg-path.js @@ -15,6 +15,7 @@ import cds from '@sap/cds' import { kgQuery } from './kg-sparql-client.js' import { kgPathV2 } from './kg-path-v2-client.js' +import { isFlagEnabled } from './feature-flags/db-flags.js' // Single-source-of-truth tutorial IRI prefix. The PATH_BETWEEN procedure // validates p1/p2 as full tutorial IRIs (see srv/lib/kg-queries.js). @@ -72,7 +73,7 @@ export async function findPathV2OrV1({ db, fromSlug, toSlug }) { const fromIri = `${TUTORIAL_IRI_PREFIX}${fromSlug}` const toIri = `${TUTORIAL_IRI_PREFIX}${toSlug}` - if (process.env.KG_PATH_V2_ENABLED === 'true') { + if (isFlagEnabled('KG_PATH_V2_ENABLED')) { try { const paths = await kgPathV2({ fromIri, toIri }) if (paths.length > 0) { diff --git a/srv/lib/mcp-compose-router.js b/srv/lib/mcp-compose-router.js index b0938de93..5409441e1 100644 --- a/srv/lib/mcp-compose-router.js +++ b/srv/lib/mcp-compose-router.js @@ -19,6 +19,7 @@ import { getDescription } from '@cap-js/mcp/lib/utils/cds-to-schema.js'; import { registerResources as realRegisterResources } from './mcp-resources.js'; import { loadPrompts, listPrompts, getPrompt } from './mcp-prompt-loader.js'; import * as metrics from './metrics.js'; +import { isFlagEnabled } from './feature-flags/db-flags.js'; const LOG = cds.log('mcp-compose'); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -33,8 +34,15 @@ export function promptMapSingleton() { } export function flags() { - const on = (v) => process.env[v] !== 'false'; - return { phase3: on('MCP_PHASE3_ENABLED'), resources: on('MCP_RESOURCES_ENABLED'), prompts: on('MCP_PROMPTS_ENABLED'), adminTools: on('MCP_ADMIN_TOOLS_ENABLED') }; + // DB-driven (ImsConfig flag.mcp.*, #2060) — all default ON. On a cold cache + // (e.g. boot-time route mount) each read returns the declared default; the + // per-request paths read the warm DB value. + return { + phase3: isFlagEnabled('MCP_PHASE3_ENABLED'), + resources: isFlagEnabled('MCP_RESOURCES_ENABLED'), + prompts: isFlagEnabled('MCP_PROMPTS_ENABLED'), + adminTools: isFlagEnabled('MCP_ADMIN_TOOLS_ENABLED'), + }; } /** Wire tools + (optionally) resources + prompts onto `server`, set capabilities. */ diff --git a/srv/lib/mcp-pat-actions.js b/srv/lib/mcp-pat-actions.js index 7f9e6d94f..0f5a51f84 100644 --- a/srv/lib/mcp-pat-actions.js +++ b/srv/lib/mcp-pat-actions.js @@ -14,6 +14,7 @@ import crypto from 'node:crypto'; import { resolveDbUser } from './resolve-db-user.js'; import { invalidateCacheByPatId } from './mcp-pat-middleware.js'; import * as metrics from './metrics.js'; +import { isFlagEnabled } from './feature-flags/db-flags.js'; const VALID_SCOPES = new Set(['read', 'write']); const MIN_TTL = 1; @@ -49,7 +50,7 @@ function generateToken() { } export async function handleMintPAT(req) { - if (process.env.MCP_PAT_MINT_ENABLED === 'false') return req.reject(503, 'PAT minting is disabled'); + if (!isFlagEnabled('MCP_PAT_MINT_ENABLED')) return req.reject(503, 'PAT minting is disabled'); const { name, scopes, ttlDays } = req.data; if (!name || typeof name !== 'string') return req.error(400, 'name is required'); try { assertValidScopes(scopes); } catch (e) { return req.error(400, e.message); } diff --git a/srv/lib/metrics-db-wrap.js b/srv/lib/metrics-db-wrap.js index 1eb53b9bb..67fa25b33 100644 --- a/srv/lib/metrics-db-wrap.js +++ b/srv/lib/metrics-db-wrap.js @@ -28,6 +28,7 @@ // - db.pool.timeout counter — error.message matches /timeout|acquire/i import * as metrics from './metrics.js'; +import { isFlagEnabled } from './feature-flags/db-flags.js'; // Observe promise resolution/rejection without altering the caller's chain. // Non-thenable values (unlikely for cds.db.run/tx but defensive) are recorded @@ -67,7 +68,7 @@ function timeAndCount(promise, metricName) { * false if already installed, no db, or env-flags disabled. */ export function installDbWrap(cds) { - if (process.env.METRICS_ENABLED === 'false') return false; + if (!isFlagEnabled('METRICS_ENABLED')) return false; if (process.env.METRICS_DB_WRAP !== 'true') return false; if (globalThis.__metricsDbWrapInstalled) return false; if (!cds?.db || typeof cds.db.run !== 'function' || typeof cds.db.tx !== 'function') { diff --git a/srv/lib/metrics.js b/srv/lib/metrics.js index 9ea1722a9..e9253f041 100644 --- a/srv/lib/metrics.js +++ b/srv/lib/metrics.js @@ -15,10 +15,13 @@ // Behavior contract: // - No public call ever throws to the caller. All wrapped in try/catch // that funnels to a rate-limited warn. -// - When METRICS_ENABLED === 'false', all writes are no-ops and snapshot() -// returns the stable empty shape { counters:{}, gauges:{}, histograms:{} }. +// - When the METRICS_ENABLED feature flag is off (ImsConfig flag.metrics), +// all writes are no-ops and snapshot() returns the stable empty shape +// { counters:{}, gauges:{}, histograms:{} }. // - The module owns in-memory state only. Persistence is the rollup job. +import { isFlagEnabled } from './feature-flags/db-flags.js'; + const counters = new Map(); const gauges = new Map(); const RESERVOIR_SIZE = 2000; @@ -40,7 +43,7 @@ function warn(msg) { } function isDisabled() { - return process.env.METRICS_ENABLED === 'false'; + return !isFlagEnabled('METRICS_ENABLED'); } export function counter(name, n = 1) { diff --git a/srv/lib/tutorial-step-slicer.js b/srv/lib/tutorial-step-slicer.js index 3e48f084b..69eb72e63 100644 --- a/srv/lib/tutorial-step-slicer.js +++ b/srv/lib/tutorial-step-slicer.js @@ -14,6 +14,7 @@ import { gunzipSync } from 'node:zlib'; import { Readable } from 'node:stream'; import * as cheerio from 'cheerio'; import * as metrics from './metrics.js'; +import { isFlagEnabled } from './feature-flags/db-flags.js'; const NS = 'com.sap.developers.ims'; const LOG = cds.log('mcp-slicer'); @@ -70,7 +71,7 @@ async function getActiveVersion() { } async function loadAndParse(slug) { - if (process.env.KG_STEP_SLICER_ENABLED === 'false') return null; + if (!isFlagEnabled('KG_STEP_SLICER_ENABLED')) return null; const version = await getActiveVersion(); if (!version) return null; diff --git a/srv/server.js b/srv/server.js index 3a35d2245..0367a3558 100644 --- a/srv/server.js +++ b/srv/server.js @@ -51,6 +51,7 @@ import { invalidate as invalidateAlertsCache } from './lib/alerts-cache.js'; import { resolveUser, captureUserMiddleware } from './lib/resolve-user.js'; import { patMiddleware, pinPatUserToContext } from './lib/mcp-pat-middleware.js'; import makeComposeRouter, { flags as mcpFlags } from './lib/mcp-compose-router.js'; +import { isFlagEnabled } from './lib/feature-flags/db-flags.js'; import { buildSystemPrompt } from './lib/chat-context.js'; import { createRateLimiter, RateLimitError } from './lib/chat-rate-limit.js'; import { createIpRateLimiter, ipRateLimitMiddleware } from './lib/ip-rate-limit.js'; @@ -697,14 +698,16 @@ cds.on('bootstrap', (app) => { // bootstrap before CAP mounts A2aService at /a2a. (#1220) app.post('/a2a', express.json({ limit: '64kb' }), (req, res, next) => a2aHandler(req, res, next)); - // MCP_AUTH_ENABLED kill switch — when explicitly set to 'false', return 503 + // MCP_AUTH_ENABLED kill switch — when the flag is off, return 503 // for all /mcp-auth and /mcp-pat routes. This must come BEFORE the PAT // middleware registration so the kill switch short-circuits the whole stack. - // (Phase 2 Task 15 #1105) - if (process.env.MCP_AUTH_ENABLED === 'false') { + // (Phase 2 Task 15 #1105; DB-driven ImsConfig flag.mcp.auth since #2060 — + // NOTE: this bootstrap-time read runs on a cold flag cache and so honors the + // declared default (ON); the DB value gates the warm per-request paths.) + if (!isFlagEnabled('MCP_AUTH_ENABLED')) { app.use('/mcp-auth', (_req, res) => res.status(503).send('Phase 2 MCP auth disabled')); app.use('/mcp-pat', (_req, res) => res.status(503).send('Phase 2 MCP auth disabled')); - cds.log('mcp').warn('MCP_AUTH_ENABLED=false — /mcp-auth and /mcp-pat return 503'); + cds.log('mcp').warn('MCP_AUTH_ENABLED flag off — /mcp-auth and /mcp-pat return 503'); } // /mcp-auth/* (OAuth tier) → /mcp/* rewrite (Phase 2 #1105). The approuter @@ -1092,6 +1095,43 @@ cds.on('served', async () => { globalThis.__feedbackBeforeHookRegistered = true; } + // Warm the Content Option-B delta flags (ImsConfig-backed) once at boot so the + // synchronous hot-path getters (isDeltaRead/isDeltaWrite/isDeltaSkipCarryForward + // in content-store.js et al.) return the real DB values before the first + // request rather than the cold-cache fail-safe default. Fail-open: any read + // fault leaves the safe defaults (all false → legacy ContentFiles path) and + // MUST NOT crash boot. + try { + const { ensureContentDeltaDefaults, refreshContentDeltaFlags } = await import('./lib/content-delta-flags.js'); + // Seed absent flags to 'true' so the fast path defaults ON and survives + // deploys (data, not env). Only fills missing keys — admin overrides stick. + // Skip under the vitest harness: unit/hybrid tests rely on the resolver's + // fail-safe-OFF default (legacy ContentFiles path) and set flag state + // explicitly per case. Seeding all three 'true' at boot leaked into every + // pre-existing content-store/publish/nav test — nav/hashes/catalog reads hit + // the empty ContentCurrent and rollback replayed instead of clearing. VITEST + // is set only by the test runner, never in CF or `cds watch`, so production + // and local dev still default-ON. + if (!process.env.VITEST) await ensureContentDeltaDefaults(); + await refreshContentDeltaFlags(); + } catch (err) { + cds.log('content-delta-flags').warn('boot warm-up failed (non-fatal):', err.message); + } + + // Warm the generic ImsConfig-backed feature flags (issue #2060) the same way: + // seed any absent flag row to its DECLARED registry default, then warm the + // 60s cache so the synchronous isFlagEnabled() getters (metrics, MCP, KG jobs, + // homepage news relevance, freshness scan, etc.) return real DB values before + // the first request rather than the cold-cache declared default. Fail-open: + // any fault leaves the safe declared defaults and MUST NOT crash boot. + try { + const { ensureFeatureFlagDefaults, refreshFeatureFlags } = await import('./lib/feature-flags/db-flags.js'); + await ensureFeatureFlagDefaults(); + await refreshFeatureFlags(); + } catch (err) { + cds.log('feature-flags').warn('boot warm-up failed (non-fatal):', err.message); + } + // #658 — one-shot purge of accumulated noise rows in sap.changelog.Changes // for entities whose @changelog annotation was retroactively dropped. Held // behind a JobLocks sentinel so it runs exactly once per CF deploy across @@ -1361,6 +1401,24 @@ cds.on('served', async () => { } } + // Seed the HomepageShelves baseline (verb-page shelf entries + footer links, + // including third-party content). Idempotent + non-destructive: inserts only + // missing (verb,url) rows, never overwrites admin edits. Replaces the retired + // seed CSV (whose .hdbtabledata full-replaced the table on deploy and wiped + // curated/third-party rows) and the manual `seed:thirdparty` step. Non-fatal. + if (!globalThis.__homepageShelvesSeeded) { + globalThis.__homepageShelvesSeeded = true; + try { + const { seedHomepageShelves } = await import('./lib/homepage/seed-homepage-shelves.js'); + const result = await seedHomepageShelves(cds.db); + if (result.inserted > 0) { + console.log(`[homepage-shelves] seeded ${result.inserted}/${result.total} baseline entries`); + } + } catch (err) { + console.warn('[homepage-shelves] seed failed (non-fatal):', err.message); + } + } + app.get('/auth/user', contextMw, authMw, async (req, res) => { // #1268: coarse deploy environment (DEV/PROD/QA/LOCAL) for the admin // header. Derived from the CF space name — safe to expose to anonymous diff --git a/test/data/com.sap.developers.ims-HomepageShelves.csv b/test/data/com.sap.developers.ims-HomepageShelves.csv deleted file mode 100644 index 8a61e08a8..000000000 --- a/test/data/com.sap.developers.ims-HomepageShelves.csv +++ /dev/null @@ -1,75 +0,0 @@ -ID;verb;shelf;sortOrder;title;url;description;badge;isExternal;isActive -66333900-0001-0001-0001-000000000001;LEARN;START_HERE;10;Tutorial Navigator;/tutorial-navigator/;Browse and filter 1 400+ hands-on tutorials across all SAP topics;;false;true -66333900-0001-0001-0001-000000000002;LEARN;START_HERE;20;SAP Learning Journeys;https://learning.sap.com/learning-journeys;Curated multi-course paths toward certifications and role mastery;;true;true -66333900-0001-0001-0001-000000000003;LEARN;START_HERE;30;New to Cloud SAP?;/missions/;Guided mission paths for developers moving from on-prem to cloud and AI;;false;true -66333900-0001-0001-0001-000000000004;LEARN;REFERENCE;40;learning.sap.com;https://learning.sap.com;Free and paid learning journeys, self-paced courses, and certification prep;;true;true -66333900-0001-0001-0001-000000000005;LEARN;REFERENCE;50;SAP Help Portal;https://help.sap.com;Centralized product documentation for all SAP products;;true;true -66333900-0001-0001-0001-000000000006;LEARN;REFERENCE;60;Certifications;https://www.sap.com/training-certification.html;Official SAP certification paths and instructor-led training;;true;true -66333900-0001-0001-0001-000000000007;LEARN;TOOLS;70;BTP Free Tier Signup;https://cockpit.btp.cloud.sap;Start building on SAP BTP with a free tier account — no credit card required;;true;true -66333900-0001-0001-0001-000000000008;LEARN;TOOLS;80;SAP-samples on GitHub;https://github.com/SAP-samples;Hundreds of sample apps, tutorials, and workshop repos from SAP;;true;true -66333900-0001-0001-0001-000000000009;LEARN;KEEP_CURRENT;90;SAP Developer News;https://youtube.com/@sapdevs;Weekly Friday show covering the latest developer news, tutorials, and events;;true;true -66333900-0001-0002-0001-000000000001;BUILD;START_HERE;10;SAP CAP;https://cap.cloud.sap;Official docs for the Cloud Application Programming Model — Node.js + Java;;true;true -66333900-0001-0002-0001-000000000002;BUILD;START_HERE;20;ABAP Cloud + RAP;https://help.sap.com/docs/abap-cloud;Clean-core ABAP development with RAP, ADT, and the BTP ABAP environment;;true;true -66333900-0001-0002-0001-000000000003;BUILD;START_HERE;30;Fiori / UI5;https://ui5.sap.com;SAP UI5 Demo Kit — API reference, samples, and interactive demos;;true;true -66333900-0001-0002-0001-000000000004;BUILD;START_HERE;40;SAP Build;https://www.sap.com/products/technology-platform/build.html;Low-code app, automation, and website suite on BTP for faster delivery;;true;true -66333900-0001-0002-0001-000000000005;BUILD;START_HERE;50;SAP Build Code;https://www.sap.com/products/technology-platform/build-code.html;AI-assisted pro-code development tooling on BTP with Joule co-pilot;;true;true -66333900-0001-0002-0001-000000000006;BUILD;REFERENCE;60;SAP Cloud SDK;https://sap.github.io/cloud-sdk/;Java + JavaScript SDKs for consuming SAP APIs from any cloud app;;true;true -66333900-0001-0002-0001-000000000007;BUILD;REFERENCE;70;Fiori Design System;https://www.sap.com/design-system/;SAP Fiori design guidelines for web, mobile, and Build apps;;true;true -66333900-0001-0002-0001-000000000008;BUILD;REFERENCE;80;Business Application Studio;https://help.sap.com/docs/SAP_BUSINESS_APPLICATION_STUDIO;Official docs for the cloud IDE purpose-built for SAP development;;true;true -66333900-0001-0002-0001-000000000009;BUILD;TOOLS;90;SAP Development Tools;https://tools.hana.ondemand.com;Download portal for Eclipse plugins, CLIs, HANA client, and SDKs;;true;true -66333900-0001-0002-0001-000000000010;BUILD;TOOLS;100;UI5 Web Components for React;https://sap.github.io/ui5-webcomponents-react/;React wrappers for UI5 web components — enterprise-grade UI in React;;true;true -66333900-0001-0002-0001-000000000011;BUILD;KEEP_CURRENT;110;SAP Tech Bytes;https://youtube.com/@sapdevs;Short-form, code-focused SAP Developers YouTube videos;;true;true -66333900-0001-0002-0001-000000000012;BUILD;KEEP_CURRENT;120;CAP Community Blogs;https://community.sap.com/t5/all-sap-community-blogs/ct-p/all-blogs;Latest community articles on CAP, ABAP, Fiori, and BTP development;;true;true -66333900-0001-0002-0001-000000000013;BUILD;KEEP_CURRENT;130;CodeJams;/tutorial-navigator/;Hands-on in-person and virtual workshops delivered by SAP experts;;false;true -66333900-0001-0003-0001-000000000001;INTEGRATE;START_HERE;10;SAP Business Accelerator Hub;https://api.sap.com;Browse and test SAP APIs across S/4HANA, SuccessFactors, Ariba, and more;;true;true -66333900-0001-0003-0001-000000000002;INTEGRATE;START_HERE;20;SAP Integration Suite;https://help.sap.com/docs/integration-suite;Docs for Cloud Integration, API Management, and Event Mesh on BTP;;true;true -66333900-0001-0003-0001-000000000003;INTEGRATE;START_HERE;30;Your First Integration Flow;/tutorial-navigator/;Step-by-step tutorials for building your first integration on SAP BTP;;false;true -66333900-0001-0003-0001-000000000004;INTEGRATE;REFERENCE;40;SAP Event Mesh;https://help.sap.com/docs/event-mesh;Managed message broker for event-driven architectures on BTP;;true;true -66333900-0001-0003-0001-000000000005;INTEGRATE;REFERENCE;50;SAP Destination Service;https://help.sap.com/docs/destination-service;BTP service that brokers credentials to external endpoints securely;;true;true -66333900-0001-0003-0001-000000000006;INTEGRATE;REFERENCE;60;Open Resource Discovery;https://open-resource-discovery.org;Open protocol for publishing and discovering application + service metadata;;true;true -66333900-0001-0003-0001-000000000007;INTEGRATE;REFERENCE;70;SAP OData Vocabularies;https://sap.github.io/odata-vocabularies/;OData annotation term definitions across SAP business domains;;true;true -66333900-0001-0003-0001-000000000008;INTEGRATE;TOOLS;80;Project Piper;https://www.project-piper.io/;Pre-built CI/CD pipelines and shared library for SAP application delivery;;true;true -66333900-0001-0003-0001-000000000009;INTEGRATE;TOOLS;90;Integration Samples;https://github.com/SAP-samples;Integration flow samples, API patterns, and event-driven architecture demos;;true;true -66333900-0001-0004-0001-000000000001;OPERATE;START_HERE;10;SAP BTP Cockpit;https://cockpit.btp.cloud.sap;Browser console for managing BTP global accounts, subaccounts, and spaces;;true;true -66333900-0001-0004-0001-000000000002;OPERATE;START_HERE;20;BTP CLI;https://help.sap.com/docs/btp-cli;Command-line reference for the `btp` tool — automate BTP account management;;true;true -66333900-0001-0004-0001-000000000003;OPERATE;START_HERE;30;BTP Getting Started;https://help.sap.com/docs/btp/sap-business-technology-platform/getting-started;Onboarding entry point and checklist for SAP Business Technology Platform;;true;true -66333900-0001-0004-0001-000000000004;OPERATE;REFERENCE;40;SAP BTP Documentation;https://help.sap.com/docs/btp;Canonical reference docs for SAP Business Technology Platform;;true;true -66333900-0001-0004-0001-000000000005;OPERATE;REFERENCE;50;SAP Discovery Center;https://discovery-center.cloud.sap;BTP service catalog, guided missions, estimator, and pricing;;true;true -66333900-0001-0004-0001-000000000006;OPERATE;REFERENCE;60;Kyma;https://kyma-project.io;Open-source Kubernetes platform for cloud-native BTP extensions;;true;true -66333900-0001-0004-0001-000000000007;OPERATE;REFERENCE;70;SAP HANA Cloud;https://help.sap.com/docs/SAP_HANA_CLOUD;Canonical docs for HANA Cloud in-memory database with vector engine;;true;true -66333900-0001-0004-0001-000000000008;OPERATE;TOOLS;80;Business Application Studio;https://help.sap.com/docs/SAP_BUSINESS_APPLICATION_STUDIO;Cloud IDE purpose-built for CAP, Fiori, mobile, and full-stack BTP development;;true;true -66333900-0001-0004-0001-000000000009;OPERATE;TOOLS;90;SAP Development Tools;https://tools.hana.ondemand.com;Download Eclipse plugins, CLIs, HANA client, and SDKs;;true;true -66333900-0001-0005-0001-000000000001;AI;START_HERE;10;BTP AI Best Practices;https://btp-ai-bp.docs.sap/;Curated guides and architecture patterns for building AI apps on BTP;;true;true -66333900-0001-0005-0001-000000000002;AI;START_HERE;20;AI Skills Library;https://skills.cloud.sap/;Searchable catalog of certified AI skills built on SAP BTP;;true;true -66333900-0001-0005-0001-000000000009;AI;START_HERE;25;Joule Studio;https://help.sap.com/docs/joule-studio;Low-code authoring environment for custom Joule skills, prompts, and tool integrations — next-generation edition (Q3 2026);NEW;true;true -66333900-0001-0005-0001-000000000003;AI;START_HERE;30;Your First Joule Extension;/tutorial-navigator/;Tutorial series for building custom Joule extensions with the AI SDK;;false;true -66333900-0001-0005-0001-000000000004;AI;REFERENCE;40;SAP Joule;https://help.sap.com/docs/joule;Docs for SAP's generative-AI copilot embedded across SAP products;;true;true -66333900-0001-0005-0001-000000000010;AI;REFERENCE;45;Joule Studio (Classic);https://help.sap.com/docs/joule-studio#classic;Classic Joule Studio edition — GA since December 2025 — for teams already shipping skills today;;true;true -66333900-0001-0005-0001-000000000005;AI;REFERENCE;50;SAP AI Core;https://help.sap.com/docs/sap-ai-core;Runtime for training and serving ML + GenAI models on BTP;;true;true -66333900-0001-0005-0001-000000000006;AI;REFERENCE;60;SAP AI Launchpad;https://help.sap.com/docs/sap-ai-launchpad;Web UI to manage AI scenarios, models, and deployments on BTP;;true;true -66333900-0001-0005-0001-000000000007;AI;TOOLS;70;AI4U Use Case Repository;https://ai4u-website.cfapps.eu10-004.hana.ondemand.com;50+ real-world AI projects built on SAP BTP — searchable by scenario;;true;true -66333900-0001-0005-0001-000000000008;AI;TOOLS;80;RAG on HANA Cookbook;https://github.com/SAP-samples;Retrieval-augmented generation patterns using HANA Cloud vector engine;;true;true -66333900-0001-0006-0001-000000000001;CONNECT;START_HERE;10;SAP Community;https://community.sap.com;Networking, Q&A, blogs, and groups for all SAP developer roles;;true;true -66333900-0001-0006-0001-000000000002;CONNECT;START_HERE;20;SAP Developers YouTube;https://youtube.com/@sapdevs;Tutorial videos, Tech Bytes, Developer News, and live streams;;true;true -66333900-0001-0006-0001-000000000003;CONNECT;START_HERE;30;Devtoberfest;https://community.sap.com/t5/devtoberfest/gh-p/Devtoberfest;Annual open developer celebration with sessions, challenges, and prizes;;true;true -66333900-0001-0006-0001-000000000004;CONNECT;REFERENCE;40;SAP News Center;https://news.sap.com;Official corporate news, customer stories, and product announcements;;true;true -66333900-0001-0006-0001-000000000005;CONNECT;REFERENCE;50;Community Blogs;https://community.sap.com/t5/all-sap-community-blogs/ct-p/all-blogs;Aggregated blog stream from the SAP Community;;true;true -66333900-0001-0006-0001-000000000006;CONNECT;REFERENCE;60;Developer Advocates;/developer-advocates/;Meet the SAP Developer Advocate team and find their content;;false;true -66333900-0001-0006-0001-000000000007;CONNECT;TOOLS;70;SAP on GitHub;https://github.com/SAP;SAP's open-source projects and repositories on GitHub;;true;true -66333900-0001-0006-0001-000000000008;CONNECT;TOOLS;80;SAP-samples on GitHub;https://github.com/SAP-samples;Sample code, tutorial repos, and workshop materials from SAP;;true;true -66333900-0001-0006-0001-000000000009;CONNECT;TOOLS;90;SAP-docs on GitHub;https://github.com/SAP-docs;Markdown sources for BTP/UI5/ABAP docs — public PRs welcome;HIDDEN_GEM;true;true -66333900-0001-0006-0001-000000000010;CONNECT;KEEP_CURRENT;100;SAP TechEd;https://www.sap.com/about/events/sap-teched.html;Annual developer conference with sessions, workshops, and hands-on labs;;true;true -66333900-0001-0006-0001-000000000011;CONNECT;KEEP_CURRENT;110;ASUG;https://www.asug.com;North America's largest SAP user community — events and networking;THIRD_PARTY;true;true -66333900-0001-0006-0001-000000000012;CONNECT;KEEP_CURRENT;120;SAPinsider;https://sapinsider.org;Independent SAP professional community publication and events;THIRD_PARTY;true;true -66333900-1029-1001-0001-000000000001;MODEL;START_HERE;10;SAP HANA Cloud;https://help.sap.com/docs/hana-cloud;Managed in-memory database with columnar analytics, spatial, and graph capabilities;;true;true -66333900-1029-1001-0001-000000000002;MODEL;START_HERE;20;SAP Datasphere;https://help.sap.com/docs/SAP_DATASPHERE;Business data fabric — model, federate, and govern data across SAP and third-party sources;;true;true -66333900-1029-1001-0001-000000000003;MODEL;START_HERE;30;SAP Business Data Cloud;https://www.sap.com/products/data-cloud/business-data-cloud.html;Unified data platform that combines SAP + Databricks for AI-ready business data;NEW;true;true -66333900-1029-1002-0001-000000000001;MODEL;REFERENCE;40;HANA Cloud Modeling Guide;https://help.sap.com/docs/hana-cloud/sap-hana-cloud-modeling-guide/sap-hana-cloud-modeling-guide;Reference for calculation views, HDI containers, and semantic modeling in HANA Cloud;;true;true -66333900-1029-1002-0001-000000000002;MODEL;REFERENCE;50;Datasphere Modeling;https://help.sap.com/docs/SAP_DATASPHERE/9f804b8efa8043539289f42f372c4862/459f2ecb37fd4c0299edac35e1670ac6.html;Build semantic views, data flows, and analytic models in Datasphere spaces;;true;true -66333900-1029-1002-0001-000000000003;MODEL;REFERENCE;60;SAP Analytics Cloud;https://help.sap.com/docs/SAP_ANALYTICS_CLOUD;Cloud analytics — stories, dashboards, planning, and predictive on top of your data models;;true;true -66333900-1029-1003-0001-000000000001;MODEL;TOOLS;70;SAP HANA Database Explorer;https://help.sap.com/docs/hana-cloud-database/sap-hana-database-explorer/sap-hana-database-explorer;Web-based SQL console, catalog browser, and modeling tool for HANA Cloud;;true;true -66333900-1029-1003-0001-000000000002;MODEL;TOOLS;80;hana-cli;https://www.npmjs.com/package/hana-cli;Command-line toolkit for HANA — SQL, table inspection, data profiling, and imports;;true;true -66333900-1029-1003-0001-000000000003;MODEL;TOOLS;90;Data Model Samples;https://github.com/SAP-samples?q=hana&type=all;Canonical HANA, Datasphere, and analytics data-model examples on SAP-samples;;true;true -66333900-1029-1004-0001-000000000001;MODEL;KEEP_CURRENT;100;HANA Cloud What's New;https://help.sap.com/docs/hana-cloud/sap-hana-cloud-what-s-new/sap-hana-cloud-what-s-new;Release notes for HANA Cloud — new features and quarterly capability additions;;true;true -66333900-1029-1004-0001-000000000002;MODEL;KEEP_CURRENT;110;Datasphere Roadmap;https://roadmaps.sap.com/board?PRODUCT=73555000100800002141;Public roadmap for SAP Datasphere — planned innovations and delivery timelines;;true;true -66333900-1029-1004-0001-000000000003;MODEL;KEEP_CURRENT;120;Data & Analytics Community;https://community.sap.com/t5/technology/ct-p/technology-blogs;SAP Community blogs and Q&A for HANA, Datasphere, BDC, and SAC practitioners;;true;true diff --git a/test/data/com.sap.developers.ims-ImsConfig.csv b/test/data/com.sap.developers.ims-ImsConfig.csv index ab50b58ee..8aa88a313 100644 --- a/test/data/com.sap.developers.ims-ImsConfig.csv +++ b/test/data/com.sap.developers.ims-ImsConfig.csv @@ -5,3 +5,6 @@ aaaaaaaa-aaaa-4aaa-8aaa-000000000003;;maxNotificationLevel;3 aaaaaaaa-aaaa-4aaa-8aaa-000000000004;;useDigestNotifications;true aaaaaaaa-aaaa-4aaa-8aaa-000000000005;;lastChanceMinLevel;3 aaaaaaaa-aaaa-4aaa-8aaa-000000000006;;lastChanceDormancyDays;60 +aaaaaaaa-aaaa-4aaa-8aaa-000000000007;;content.delta.write;false +aaaaaaaa-aaaa-4aaa-8aaa-000000000008;;content.delta.read;false +aaaaaaaa-aaaa-4aaa-8aaa-000000000009;;content.delta.skipCarryForward;false diff --git a/test/hybrid/oauth-discovery.test.js b/test/hybrid/oauth-discovery.test.js index 596603382..d84263ec3 100644 --- a/test/hybrid/oauth-discovery.test.js +++ b/test/hybrid/oauth-discovery.test.js @@ -62,4 +62,21 @@ describeIf('OAuth discovery documents (deployed dev)', { timeout: 20_000 }, () = expect(Array.isArray(doc.authorization_servers)).toBe(true); expect(doc.authorization_servers.length).toBeGreaterThan(0); }); + + it('serves /.well-known/openid-configuration identical to oauth-authorization-server', async () => { + const [as, oidc] = await Promise.all([ + fetch(`${BASE}/.well-known/oauth-authorization-server`).then(r => r.json()), + fetch(`${BASE}/.well-known/openid-configuration`).then(r => r.json()), + ]); + expect(oidc).toEqual(as); + }); + + it('serves /.well-known/mcp.json with the four servers', async () => { + const res = await fetch(`${BASE}/.well-known/mcp.json`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/application\/json/); + const doc = await res.json(); + expect(doc.servers.map(s => s.name)).toEqual(['search', 'homepage', 'graph', 'developer']); + expect(doc.authorization.protected_resource).toContain('/.well-known/oauth-protected-resource'); + }); }); diff --git a/test/unit/admin-feature-flags-toggle.test.js b/test/unit/admin-feature-flags-toggle.test.js new file mode 100644 index 000000000..a122d7f7e --- /dev/null +++ b/test/unit/admin-feature-flags-toggle.test.js @@ -0,0 +1,102 @@ +// test/unit/admin-feature-flags-toggle.test.js +// +// #2060 — bound enable()/disable() row actions on AdminService.FeatureFlags. +// The viewer is @cds.persistence.skip, so these actions flip the backing +// ImsConfig row for a kind:'db' flag and return the freshly re-resolved row. +// Generic flags (flag.*) route through db-flags.js; content.delta.* flags route +// through the dedicated content-delta module. Non-db flags reject with 400. + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import cds from '@sap/cds'; +import { bustFeatureFlagsCache } from '../../srv/lib/feature-flags/db-flags.js'; +import { bustContentDeltaFlagsCache } from '../../srv/lib/content-delta-flags.js'; + +const NS = 'com.sap.developers.ims'; +const project = cds.test('serve', '--project', '.', '--in-memory'); +const ADMIN_AUTH = { auth: { username: 'admin', password: 'admin' } }; + +let db, ImsConfig; + +async function readIms(key) { + const row = await db.run(SELECT.one.from(ImsConfig).where({ key })); + return row ? String(row.value).toLowerCase() : null; +} + +describe('AdminService.FeatureFlags bound enable/disable (#2060)', () => { + beforeEach(async () => { + db = await cds.connect.to('db'); + ({ ImsConfig } = cds.entities(NS)); + await db.run(DELETE.from(ImsConfig).where({ + key: { in: ['flag.kg.pagerank', 'content.delta.write'] }, + })); + bustFeatureFlagsCache(); + bustContentDeltaFlagsCache(); + }); + + afterAll(() => { bustFeatureFlagsCache(); bustContentDeltaFlagsCache(); }); + + it('enable() on a generic db flag upserts flag.* to true and flips the resolved row', async () => { + const res = await project.post( + `/admin/FeatureFlags(key='KG_PAGERANK_ENABLED')/AdminService.enable`, + {}, + ADMIN_AUTH, + ); + expect(res.status).toBe(200); + expect(res.data.key).toBe('KG_PAGERANK_ENABLED'); + expect(res.data.enabled).toBe(true); + expect(res.data.winningLayer).toBe('db'); + expect(await readIms('flag.kg.pagerank')).toBe('true'); + }); + + it('disable() on a generic db flag upserts flag.* to false', async () => { + // enable first, then disable. + await project.post(`/admin/FeatureFlags(key='KG_PAGERANK_ENABLED')/AdminService.enable`, {}, ADMIN_AUTH); + const res = await project.post( + `/admin/FeatureFlags(key='KG_PAGERANK_ENABLED')/AdminService.disable`, + {}, + ADMIN_AUTH, + ); + expect(res.status).toBe(200); + expect(res.data.enabled).toBe(false); + expect(await readIms('flag.kg.pagerank')).toBe('false'); + }); + + it('enable() on a content.delta.* flag routes to the content.delta ImsConfig key', async () => { + const res = await project.post( + `/admin/FeatureFlags(key='CONTENT_DELTA_WRITE_ENABLED')/AdminService.enable`, + {}, + ADMIN_AUTH, + ); + expect(res.status).toBe(200); + expect(res.data.key).toBe('CONTENT_DELTA_WRITE_ENABLED'); + expect(res.data.enabled).toBe(true); + expect(await readIms('content.delta.write')).toBe('true'); + }); + + it('rejects a non-db flag (db-setting) with 400', async () => { + const res = await project.post( + `/admin/FeatureFlags(key='KNOWLEDGE_GRAPH_ENABLED')/AdminService.enable`, + {}, + { ...ADMIN_AUTH, validateStatus: () => true }, + ); + expect(res.status).toBe(400); + }); + + it('rejects a constant flag with 400', async () => { + const res = await project.post( + `/admin/FeatureFlags(key='KG_WEIGHT')/AdminService.disable`, + {}, + { ...ADMIN_AUTH, validateStatus: () => true }, + ); + expect(res.status).toBe(400); + }); + + it('rejects unauthenticated callers', async () => { + const res = await project.post( + `/admin/FeatureFlags(key='KG_PAGERANK_ENABLED')/AdminService.enable`, + {}, + { validateStatus: () => true }, + ); + expect(res.status).toBe(401); + }); +}); diff --git a/test/unit/community-blogs-classifier.test.js b/test/unit/community-blogs-classifier.test.js index 2a4bc1cff..32b6c632f 100644 --- a/test/unit/community-blogs-classifier.test.js +++ b/test/unit/community-blogs-classifier.test.js @@ -13,6 +13,7 @@ import { classifyPendingBatch, isClassifierEnabled, } from '../../srv/lib/community-blogs-classifier.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; cds.test('serve', '--project', '.', '--in-memory'); @@ -42,23 +43,18 @@ function toolCall({ verdict, confidence, reason }) { // ----------------------------------------------------------------------------- describe('isClassifierEnabled', () => { - const orig = process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED; - afterAll(() => { - // Restore env so the next describe doesn't inherit our last-test setting. - if (orig === undefined) delete process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED; - else process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED = orig; - }); + afterAll(() => { __resetFlagsForTest(); }); - it('enabled by default (env unset)', () => { - delete process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED; + it('enabled by default (flag unset)', () => { + __resetFlagsForTest(); expect(isClassifierEnabled()).toBe(true); }); - it('enabled when set to "true"', () => { - process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED = 'true'; + it('enabled when flag set true', () => { + __setFlagForTest('COMMUNITY_BLOGS_CLASSIFIER_ENABLED', true); expect(isClassifierEnabled()).toBe(true); }); - it('disabled when set to "false"', () => { - process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED = 'false'; + it('disabled when flag set false', () => { + __setFlagForTest('COMMUNITY_BLOGS_CLASSIFIER_ENABLED', false); expect(isClassifierEnabled()).toBe(false); }); }); @@ -199,15 +195,13 @@ describe('classifyPendingBatch', () => { }); it('returns disabled:true when kill switch is on', async () => { - const orig = process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED; - process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED = 'false'; + __setFlagForTest('COMMUNITY_BLOGS_CLASSIFIER_ENABLED', false); try { const summary = await classifyPendingBatch({ limit: 10 }); expect(summary.disabled).toBe(true); expect(summary.drained).toBe(0); } finally { - if (orig === undefined) delete process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED; - else process.env.COMMUNITY_BLOGS_CLASSIFIER_ENABLED = orig; + __resetFlagsForTest(); } }); diff --git a/test/unit/content-delta-carryforward-skip.test.js b/test/unit/content-delta-carryforward-skip.test.js index 1ba8e62fd..17ea60c38 100644 --- a/test/unit/content-delta-carryforward-skip.test.js +++ b/test/unit/content-delta-carryforward-skip.test.js @@ -14,6 +14,10 @@ import cds from '@sap/cds'; import { gzipSync, gunzipSync } from 'node:zlib'; import { createSessionHelpers } from '../../srv/lib/content-publish-session.js'; import { createContentHandlers, invalidateContentCache } from '../../srv/lib/content-store.js'; +import { + refreshContentDeltaFlags, bustContentDeltaFlagsCache, + DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY, +} from '../../srv/lib/content-delta-flags.js'; const NS = 'com.sap.developers.ims'; cds.test('serve', '--project', '.', '--in-memory'); @@ -28,18 +32,29 @@ function makeRes() { describe('Option B carry-forward skip + rollback replay (Workstream D 8.4)', () => { let helpers, serveHandler, rollbackHandler; - let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks; - const saved = {}; - const FLAGS = ['CONTENT_DELTA_WRITE_ENABLED', 'CONTENT_DELTA_READ_ENABLED', 'CONTENT_DELTA_SKIP_CARRYFORWARD']; + let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks, ImsConfig; + const DELTA_KEYS = [DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY]; + + // All three delta flags ON for this suite. Seed ImsConfig then warm the cache. + async function enableAllDeltaFlags() { + for (const key of DELTA_KEYS) { + const existing = await SELECT.one.from(ImsConfig).where({ key }); + if (existing) await UPDATE(ImsConfig, existing.ID).set({ value: 'true' }); + else await INSERT.into(ImsConfig).entries({ key, value: 'true' }); + } + await refreshContentDeltaFlags(); + } beforeAll(() => { helpers = createSessionHelpers({ namespace: NS }); - ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks } = cds.entities(NS)); - for (const f of FLAGS) { saved[f] = process.env[f]; process.env[f] = 'true'; } + ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks, ImsConfig } = cds.entities(NS)); }); - afterAll(() => { for (const f of FLAGS) { if (saved[f] === undefined) delete process.env[f]; else process.env[f] = saved[f]; } }); + afterAll(() => { bustContentDeltaFlagsCache(); }); beforeEach(async () => { for (const e of [ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks]) await DELETE.from(e); + await DELETE.from(ImsConfig).where({ key: { in: DELTA_KEYS } }); + bustContentDeltaFlagsCache(); + await enableAllDeltaFlags(); // Fresh handlers per test → fresh in-memory content cache (in prod a publish // busts it via the cache-generation token; the test calls commitSession directly). ({ serveHandler, rollbackHandler } = createContentHandlers()); diff --git a/test/unit/content-delta-dualwrite.test.js b/test/unit/content-delta-dualwrite.test.js index b4a61cca1..fc62fad41 100644 --- a/test/unit/content-delta-dualwrite.test.js +++ b/test/unit/content-delta-dualwrite.test.js @@ -2,13 +2,16 @@ // // Workstream D (slug-targeted-delta-rebuild) — Option B dual-write guard. // -// When CONTENT_DELTA_WRITE_ENABLED=true, commitSession mirrors the freshly- -// published slugs into the mutable ContentCurrent table (one row per slug, no -// version) + appends WRITTEN rows to ContentHistory, ALONGSIDE the legacy -// ContentFiles write. This test drives publishes on in-memory SQLite and +// When the content.delta.write ImsConfig flag is 'true', commitSession mirrors +// the freshly-published slugs into the mutable ContentCurrent table (one row per +// slug, no version) + appends WRITTEN rows to ContentHistory, ALONGSIDE the +// legacy ContentFiles write. This test drives publishes on in-memory SQLite and // asserts: (a) ContentCurrent is one-row-per-slug and UPSERTs on republish, // (b) ContentHistory accumulates per version, (c) the flag OFF writes neither. // +// The flags moved from process.env.* to ImsConfig (DB-driven config); the tests +// seed the ImsConfig row and warm the cached getter via refreshContentDeltaFlags(). +// // HANA LOB-locator behavior is NOT exercised here (SQLite CQL path); that is // covered by the hybrid publish→rollback test in Workstream D task 7.4. @@ -16,6 +19,9 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import cds from '@sap/cds'; import { gzipSync } from 'node:zlib'; import { createSessionHelpers } from '../../srv/lib/content-publish-session.js'; +import { + refreshContentDeltaFlags, bustContentDeltaFlagsCache, DELTA_WRITE_KEY, +} from '../../srv/lib/content-delta-flags.js'; const NS = 'com.sap.developers.ims'; @@ -36,17 +42,23 @@ async function appendAll(helpers, sessionId, slugs) { describe('Option B dual-write (Workstream D)', () => { let helpers; - let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks; - const prevFlag = process.env.CONTENT_DELTA_WRITE_ENABLED; + let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks, ImsConfig; + + // Upsert content.delta.write into ImsConfig then warm the cached getter so the + // synchronous isDeltaWrite() consulted in commitSession sees the new value. + async function setDeltaWrite(on) { + const value = String(Boolean(on)); + const existing = await SELECT.one.from(ImsConfig).where({ key: DELTA_WRITE_KEY }); + if (existing) await UPDATE(ImsConfig, existing.ID).set({ value }); + else await INSERT.into(ImsConfig).entries({ key: DELTA_WRITE_KEY, value }); + await refreshContentDeltaFlags(); + } beforeAll(() => { helpers = createSessionHelpers({ namespace: NS }); - ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks } = cds.entities(NS)); - }); - afterAll(() => { - if (prevFlag === undefined) delete process.env.CONTENT_DELTA_WRITE_ENABLED; - else process.env.CONTENT_DELTA_WRITE_ENABLED = prevFlag; + ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks, ImsConfig } = cds.entities(NS)); }); + afterAll(() => { bustContentDeltaFlagsCache(); }); beforeEach(async () => { await DELETE.from(ContentFiles); await DELETE.from(ContentManifest); @@ -54,6 +66,8 @@ describe('Option B dual-write (Workstream D)', () => { await DELETE.from(ContentHistory); await DELETE.from(PipelineLog); await DELETE.from(JobLocks); + await DELETE.from(ImsConfig).where({ key: DELTA_WRITE_KEY }); + bustContentDeltaFlagsCache(); }); it('exposes ContentCurrent + ContentHistory entities', () => { @@ -62,7 +76,7 @@ describe('Option B dual-write (Workstream D)', () => { }); it('writes ContentCurrent (one row per slug) + ContentHistory when the flag is ON', async () => { - process.env.CONTENT_DELTA_WRITE_ENABLED = 'true'; + await setDeltaWrite(true); const slugs = ['a', 'b', 'c']; const s = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: slugs.length, initiator: 'test' }); await appendAll(helpers, s.sessionId, slugs); @@ -80,7 +94,7 @@ describe('Option B dual-write (Workstream D)', () => { }, 60_000); it('UPSERTs ContentCurrent on republish (stays one row per slug) + appends history per version', async () => { - process.env.CONTENT_DELTA_WRITE_ENABLED = 'true'; + await setDeltaWrite(true); const slugs = ['a', 'b', 'c']; const s1 = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: 3, initiator: 'test' }); await appendAll(helpers, s1.sessionId, slugs); @@ -105,7 +119,7 @@ describe('Option B dual-write (Workstream D)', () => { }, 60_000); it('writes NEITHER table when the flag is OFF', async () => { - process.env.CONTENT_DELTA_WRITE_ENABLED = 'false'; + await setDeltaWrite(false); const s = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: 2, initiator: 'test' }); await appendAll(helpers, s.sessionId, ['x', 'y']); await helpers.commitSession({ sessionId: s.sessionId }); diff --git a/test/unit/content-delta-flags.test.js b/test/unit/content-delta-flags.test.js new file mode 100644 index 000000000..48e52c3e4 --- /dev/null +++ b/test/unit/content-delta-flags.test.js @@ -0,0 +1,157 @@ +// test/unit/content-delta-flags.test.js +// +// Unit coverage for the ImsConfig-backed Content Option-B delta flags +// (srv/lib/content-delta-flags.js), which replaced the CONTENT_DELTA_* env vars. +// +// Asserts the fail-safe contract that guards a PROD content-serving hot path: +// - default FALSE when the ImsConfig rows are unset (legacy ContentFiles path) +// - reads true/false from ImsConfig +// - synchronous getters return the CACHED value within the TTL (no re-read) +// - bustContentDeltaFlagsCache() forces the next refresh to re-read the DB +// - a DB read error keeps the last-known value (never throws, never flips on) + +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import cds from '@sap/cds'; +import { + isDeltaWrite, isDeltaRead, isDeltaSkipCarryForward, + refreshContentDeltaFlags, bustContentDeltaFlagsCache, ensureContentDeltaDefaults, + DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY, +} from '../../srv/lib/content-delta-flags.js'; + +const NS = 'com.sap.developers.ims'; +cds.test('serve', '--project', '.', '--in-memory'); + +let ImsConfig; +const ALL = [DELTA_WRITE_KEY, DELTA_READ_KEY, DELTA_SKIP_CARRYFORWARD_KEY]; + +async function setKey(key, value) { + const existing = await SELECT.one.from(ImsConfig).where({ key }); + if (existing) await UPDATE(ImsConfig, existing.ID).set({ value: String(value) }); + else await INSERT.into(ImsConfig).entries({ key, value: String(value) }); +} + +describe('content-delta-flags (ImsConfig-backed)', () => { + beforeAll(() => { + ({ ImsConfig } = cds.entities(NS)); + }); + + beforeEach(async () => { + await DELETE.from(ImsConfig).where({ key: { in: ALL } }); + bustContentDeltaFlagsCache(); + }); + + afterAll(() => { bustContentDeltaFlagsCache(); }); + + it('defaults to false for every flag when ImsConfig rows are unset', async () => { + await refreshContentDeltaFlags(); + expect(isDeltaWrite()).toBe(false); + expect(isDeltaRead()).toBe(false); + expect(isDeltaSkipCarryForward()).toBe(false); + }); + + it('reads true/false per flag from ImsConfig', async () => { + await setKey(DELTA_WRITE_KEY, 'true'); + await setKey(DELTA_READ_KEY, 'false'); + await setKey(DELTA_SKIP_CARRYFORWARD_KEY, 'true'); + const snapshot = await refreshContentDeltaFlags(); + expect(snapshot).toEqual({ write: true, read: false, skipCarryForward: true }); + expect(isDeltaWrite()).toBe(true); + expect(isDeltaRead()).toBe(false); + expect(isDeltaSkipCarryForward()).toBe(true); + }); + + it('treats non-"true" values (including empty) as false', async () => { + await setKey(DELTA_WRITE_KEY, 'yes'); + await setKey(DELTA_READ_KEY, '1'); + await setKey(DELTA_SKIP_CARRYFORWARD_KEY, ''); + await refreshContentDeltaFlags(); + expect(isDeltaWrite()).toBe(false); + expect(isDeltaRead()).toBe(false); + expect(isDeltaSkipCarryForward()).toBe(false); + }); + + it('is case-insensitive on the string value', async () => { + await setKey(DELTA_READ_KEY, 'TRUE'); + await refreshContentDeltaFlags(); + expect(isDeltaRead()).toBe(true); + }); + + it('returns the cached value within the TTL (no re-read on DB change)', async () => { + await setKey(DELTA_READ_KEY, 'true'); + await refreshContentDeltaFlags(); + expect(isDeltaRead()).toBe(true); + + // Change the DB out from under the cache; without a refresh/bust the getter + // must keep returning the last-known (fresh) value. + await setKey(DELTA_READ_KEY, 'false'); + expect(isDeltaRead()).toBe(true); + }); + + it('bustContentDeltaFlagsCache() forces the next refresh to re-read the DB', async () => { + await setKey(DELTA_READ_KEY, 'true'); + await refreshContentDeltaFlags(); + expect(isDeltaRead()).toBe(true); + + await setKey(DELTA_READ_KEY, 'false'); + bustContentDeltaFlagsCache(); + // Cold cache after bust → synchronous getter reports the fail-safe default. + expect(isDeltaRead()).toBe(false); + // An explicit refresh re-reads and confirms the DB now says false. + await refreshContentDeltaFlags(); + expect(isDeltaRead()).toBe(false); + }); + + it('keeps the last-known value when the DB read throws (never flips on/off)', async () => { + await setKey(DELTA_WRITE_KEY, 'true'); + await refreshContentDeltaFlags(); + expect(isDeltaWrite()).toBe(true); + + // Force the next DB read to fail; the warm cache must survive intact. + const db = await cds.connect.to('db'); + const spy = vi.spyOn(db, 'run').mockRejectedValueOnce(new Error('boom')); + const snapshot = await refreshContentDeltaFlags(); + spy.mockRestore(); + + expect(snapshot.write).toBe(true); // last-known preserved + expect(isDeltaWrite()).toBe(true); + }); + + it('a DB read error on a COLD cache leaves the safe default (false)', async () => { + bustContentDeltaFlagsCache(); + const db = await cds.connect.to('db'); + const spy = vi.spyOn(db, 'run').mockRejectedValueOnce(new Error('boom')); + await refreshContentDeltaFlags(); + spy.mockRestore(); + expect(isDeltaWrite()).toBe(false); + expect(isDeltaRead()).toBe(false); + expect(isDeltaSkipCarryForward()).toBe(false); + }); + + it('ensureContentDeltaDefaults() seeds ALL absent flags to true (fast path defaults ON)', async () => { + const seeded = await ensureContentDeltaDefaults(); + expect(seeded.sort()).toEqual([...ALL].sort()); + await refreshContentDeltaFlags(); + expect(isDeltaWrite()).toBe(true); + expect(isDeltaRead()).toBe(true); + expect(isDeltaSkipCarryForward()).toBe(true); + }); + + it('ensureContentDeltaDefaults() leaves an admin-set value untouched (disable survives deploy)', async () => { + await setKey(DELTA_READ_KEY, 'false'); // admin deliberately turned READ off + const seeded = await ensureContentDeltaDefaults(); + // Only the two absent keys are seeded; the present READ row is not touched. + expect(seeded.sort()).toEqual([DELTA_WRITE_KEY, DELTA_SKIP_CARRYFORWARD_KEY].sort()); + await refreshContentDeltaFlags(); + expect(isDeltaRead()).toBe(false); // admin override preserved + expect(isDeltaWrite()).toBe(true); + expect(isDeltaSkipCarryForward()).toBe(true); + }); + + it('ensureContentDeltaDefaults() is idempotent (no duplicate rows on re-run)', async () => { + await ensureContentDeltaDefaults(); + const second = await ensureContentDeltaDefaults(); + expect(second).toEqual([]); // nothing left to seed + const rows = await SELECT.from(ImsConfig).where({ key: { in: ALL } }); + expect(rows.length).toBe(3); + }); +}); diff --git a/test/unit/content-delta-read.test.js b/test/unit/content-delta-read.test.js index 00011fed0..1d34870ca 100644 --- a/test/unit/content-delta-read.test.js +++ b/test/unit/content-delta-read.test.js @@ -2,17 +2,23 @@ // // Workstream D (slug-targeted-delta-rebuild) — Option B read cutover guard. // -// serveStoredSlug serves from the mutable ContentCurrent when -// CONTENT_DELTA_READ_ENABLED=true AND the slug exists there, else falls back to -// the legacy version-pinned ContentFiles snapshot. This keeps a partially- -// populated ContentCurrent (mid-migration) from 404-ing slugs still in +// serveStoredSlug serves from the mutable ContentCurrent when the +// content.delta.read ImsConfig flag is 'true' AND the slug exists there, else +// falls back to the legacy version-pinned ContentFiles snapshot. This keeps a +// partially-populated ContentCurrent (mid-migration) from 404-ing slugs still in // ContentFiles. X-Content-Source distinguishes the path: 'db-current' vs 'db'. +// +// The flags moved from process.env.* to ImsConfig (DB-driven config); the tests +// seed the ImsConfig row and warm the cached getter via refreshContentDeltaFlags(). import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import cds from '@sap/cds'; import { gzipSync } from 'node:zlib'; import { createSessionHelpers } from '../../srv/lib/content-publish-session.js'; import { createContentHandlers } from '../../srv/lib/content-store.js'; +import { + refreshContentDeltaFlags, bustContentDeltaFlagsCache, DELTA_WRITE_KEY, DELTA_READ_KEY, +} from '../../srv/lib/content-delta-flags.js'; const NS = 'com.sap.developers.ims'; cds.test('serve', '--project', '.', '--in-memory'); @@ -29,36 +35,39 @@ function makeRes() { end() { return this; }, }; } -async function publish(helpers, slug, body, { dualWrite }) { - const prev = process.env.CONTENT_DELTA_WRITE_ENABLED; - process.env.CONTENT_DELTA_WRITE_ENABLED = dualWrite ? 'true' : 'false'; - try { + +describe('Option B read cutover (Workstream D)', () => { + let helpers, serveHandler, rollbackHandler; + let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks, ImsConfig; + + // Upsert a content.delta.* ImsConfig flag then warm the cached getter so the + // synchronous getters in content-store.js observe the new value. + async function setDelta(key, on) { + const value = String(Boolean(on)); + const existing = await SELECT.one.from(ImsConfig).where({ key }); + if (existing) await UPDATE(ImsConfig, existing.ID).set({ value }); + else await INSERT.into(ImsConfig).entries({ key, value }); + await refreshContentDeltaFlags(); + } + + async function publish(slug, body, { dualWrite }) { + await setDelta(DELTA_WRITE_KEY, dualWrite); const s = await helpers.beginPublishSession({ trigger: 'ci/test', expectedSlugCount: 1, initiator: 'test' }); await helpers.appendToSession({ sessionId: s.sessionId, files: { [slug]: html(body) }, sources: { [slug]: source(body) } }); const res = await helpers.commitSession({ sessionId: s.sessionId }); return res.version; - } finally { - if (prev === undefined) delete process.env.CONTENT_DELTA_WRITE_ENABLED; - else process.env.CONTENT_DELTA_WRITE_ENABLED = prev; } -} - -describe('Option B read cutover (Workstream D)', () => { - let helpers, serveHandler, rollbackHandler; - let ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks; - const prevRead = process.env.CONTENT_DELTA_READ_ENABLED; beforeAll(() => { helpers = createSessionHelpers({ namespace: NS }); ({ serveHandler, rollbackHandler } = createContentHandlers()); - ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks } = cds.entities(NS)); - }); - afterAll(() => { - if (prevRead === undefined) delete process.env.CONTENT_DELTA_READ_ENABLED; - else process.env.CONTENT_DELTA_READ_ENABLED = prevRead; + ({ ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks, ImsConfig } = cds.entities(NS)); }); + afterAll(() => { bustContentDeltaFlagsCache(); }); beforeEach(async () => { for (const e of [ContentFiles, ContentManifest, ContentCurrent, ContentHistory, PipelineLog, JobLocks]) await DELETE.from(e); + await DELETE.from(ImsConfig).where({ key: { in: [DELTA_WRITE_KEY, DELTA_READ_KEY] } }); + bustContentDeltaFlagsCache(); }); async function serve(slug) { @@ -68,16 +77,16 @@ describe('Option B read cutover (Workstream D)', () => { } it('serves from ContentCurrent when read flag ON and slug is present', async () => { - await publish(helpers, 'alpha', 'A', { dualWrite: true }); - process.env.CONTENT_DELTA_READ_ENABLED = 'true'; + await publish('alpha', 'A', { dualWrite: true }); + await setDelta(DELTA_READ_KEY, true); const res = await serve('alpha'); expect(res._headers['X-Content-Source']).toBe('db-current'); expect(gunzipOrText(res._body)).toContain('A'); }); it('falls back to ContentFiles when slug is NOT in ContentCurrent (mid-migration)', async () => { - await publish(helpers, 'beta', 'B', { dualWrite: false }); // ContentFiles only - process.env.CONTENT_DELTA_READ_ENABLED = 'true'; + await publish('beta', 'B', { dualWrite: false }); // ContentFiles only + await setDelta(DELTA_READ_KEY, true); expect((await SELECT.from(ContentCurrent).where({ slug: 'beta' })).length).toBe(0); const res = await serve('beta'); expect(res._headers['X-Content-Source']).toBe('db'); @@ -85,8 +94,8 @@ describe('Option B read cutover (Workstream D)', () => { }); it('uses legacy ContentFiles when read flag is OFF even if slug is in ContentCurrent', async () => { - await publish(helpers, 'gamma', 'G', { dualWrite: true }); - process.env.CONTENT_DELTA_READ_ENABLED = 'false'; + await publish('gamma', 'G', { dualWrite: true }); + await setDelta(DELTA_READ_KEY, false); expect((await SELECT.from(ContentCurrent).where({ slug: 'gamma' })).length).toBe(1); const res = await serve('gamma'); expect(res._headers['X-Content-Source']).toBe('db'); @@ -94,9 +103,8 @@ describe('Option B read cutover (Workstream D)', () => { }); it('rollback clears ContentCurrent so reads fall back to the restored ContentFiles(V)', async () => { - process.env.CONTENT_DELTA_WRITE_ENABLED = 'true'; - const v1 = await publish(helpers, 'delta', 'D1', { dualWrite: true }); // version 1 - await publish(helpers, 'delta', 'D2', { dualWrite: true }); // version 2 (active) + const v1 = await publish('delta', 'D1', { dualWrite: true }); // version 1 + await publish('delta', 'D2', { dualWrite: true }); // version 2 (active) expect((await SELECT.from(ContentCurrent).where({ slug: 'delta' })).length).toBe(1); const rb = makeRes(); @@ -105,7 +113,7 @@ describe('Option B read cutover (Workstream D)', () => { // ContentCurrent cleared → reads fall back to ContentFiles(active=v1)=D1. expect((await SELECT.from(ContentCurrent)).length).toBe(0); - process.env.CONTENT_DELTA_READ_ENABLED = 'true'; + await setDelta(DELTA_READ_KEY, true); const s = await serve('delta'); expect(s._headers['X-Content-Source']).toBe('db'); expect(gunzipOrText(s._body)).toContain('D1'); diff --git a/test/unit/db-flags.test.js b/test/unit/db-flags.test.js new file mode 100644 index 000000000..0b615bf81 --- /dev/null +++ b/test/unit/db-flags.test.js @@ -0,0 +1,171 @@ +// test/unit/db-flags.test.js +// +// Unit coverage for the generic ImsConfig-backed feature-flag resolver +// (srv/lib/feature-flags/db-flags.js, issue #2060), which replaced the 14 +// on/off kind:'env' feature flags. Mirrors content-delta-flags.test.js but the +// fail-safe default is PER-FLAG (the declared registry default), NOT a blanket +// false — a `false-disables` kill switch (e.g. METRICS_ENABLED) stays ON through +// a cold cache or DB error, while a `true-enables` flag (e.g. KG_PAGERANK_ENABLED) +// stays OFF. + +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import cds from '@sap/cds'; +import { + isFlagEnabled, refreshFeatureFlags, bustFeatureFlagsCache, + ensureFeatureFlagDefaults, managedFlagKeys, flagMeta, +} from '../../srv/lib/feature-flags/db-flags.js'; + +const NS = 'com.sap.developers.ims'; +cds.test('serve', '--project', '.', '--in-memory'); + +let ImsConfig; + +// Representative flags: two default-true kill switches + two default-false flags. +const METRICS = 'METRICS_ENABLED'; // default true +const MCP_AUTH = 'MCP_AUTH_ENABLED'; // default true +const PAGERANK = 'KG_PAGERANK_ENABLED'; // default false +const FRESHNESS = 'FRESHNESS_SCAN_ENABLED'; // default false + +const imsKey = (registryKey) => flagMeta(registryKey).imsConfigKey; + +async function setKey(registryKey, value) { + const key = imsKey(registryKey); + const existing = await SELECT.one.from(ImsConfig).where({ key }); + if (existing) await UPDATE(ImsConfig, existing.ID).set({ value: String(value) }); + else await INSERT.into(ImsConfig).entries({ ID: cds.utils.uuid(), key, value: String(value) }); +} + +async function clearAllManaged() { + const keys = managedFlagKeys().map(imsKey); + await DELETE.from(ImsConfig).where({ key: { in: keys } }); +} + +describe('db-flags (ImsConfig-backed generic feature flags, #2060)', () => { + beforeAll(() => { ({ ImsConfig } = cds.entities(NS)); }); + + beforeEach(async () => { + await clearAllManaged(); + bustFeatureFlagsCache(); + }); + + afterAll(() => { bustFeatureFlagsCache(); }); + + it('manages the 14 migrated flags but NOT the content.delta.* keys', () => { + const keys = managedFlagKeys(); + expect(keys).toContain(METRICS); + expect(keys).toContain(PAGERANK); + expect(keys.length).toBe(14); + // content-delta flags keep their own dedicated module. + const imsKeys = keys.map(imsKey); + expect(imsKeys).not.toContain('content.delta.write'); + expect(imsKeys).not.toContain('content.delta.read'); + expect(imsKeys).not.toContain('content.delta.skipCarryForward'); + }); + + it('returns each flag\'s DECLARED default when its ImsConfig row is unset', async () => { + await refreshFeatureFlags(); + expect(isFlagEnabled(METRICS)).toBe(true); // false-disables → default ON + expect(isFlagEnabled(MCP_AUTH)).toBe(true); + expect(isFlagEnabled(PAGERANK)).toBe(false); // true-enables → default OFF + expect(isFlagEnabled(FRESHNESS)).toBe(false); + }); + + it('reads true/false per flag from ImsConfig', async () => { + await setKey(METRICS, 'false'); // disable the default-ON kill switch + await setKey(PAGERANK, 'true'); // enable the default-OFF flag + await refreshFeatureFlags(); + expect(isFlagEnabled(METRICS)).toBe(false); + expect(isFlagEnabled(PAGERANK)).toBe(true); + // Untouched flags still report their declared defaults. + expect(isFlagEnabled(MCP_AUTH)).toBe(true); + expect(isFlagEnabled(FRESHNESS)).toBe(false); + }); + + it('is case-insensitive and treats non-"true" values by their string', async () => { + await setKey(PAGERANK, 'TRUE'); + await setKey(METRICS, 'FALSE'); + await refreshFeatureFlags(); + expect(isFlagEnabled(PAGERANK)).toBe(true); + expect(isFlagEnabled(METRICS)).toBe(false); + }); + + it('returns the cached value within the TTL (no re-read on DB change)', async () => { + await setKey(PAGERANK, 'true'); + await refreshFeatureFlags(); + expect(isFlagEnabled(PAGERANK)).toBe(true); + // Change the DB out from under the fresh cache — getter keeps last-known. + await setKey(PAGERANK, 'false'); + expect(isFlagEnabled(PAGERANK)).toBe(true); + }); + + it('bustFeatureFlagsCache() drops to cold → declared default until next refresh', async () => { + await setKey(PAGERANK, 'true'); + await refreshFeatureFlags(); + expect(isFlagEnabled(PAGERANK)).toBe(true); + + await setKey(PAGERANK, 'false'); + bustFeatureFlagsCache(); + // Cold cache → declared default (false for PAGERANK). + expect(isFlagEnabled(PAGERANK)).toBe(false); + await refreshFeatureFlags(); + expect(isFlagEnabled(PAGERANK)).toBe(false); + }); + + it('keeps the last-known value when a WARM-cache DB read throws', async () => { + await setKey(PAGERANK, 'true'); + await refreshFeatureFlags(); + expect(isFlagEnabled(PAGERANK)).toBe(true); + + const db = await cds.connect.to('db'); + const spy = vi.spyOn(db, 'run').mockRejectedValueOnce(new Error('boom')); + await refreshFeatureFlags(); + spy.mockRestore(); + expect(isFlagEnabled(PAGERANK)).toBe(true); // last-known preserved + }); + + it('a DB read error on a COLD cache leaves each flag at its declared default', async () => { + bustFeatureFlagsCache(); + const db = await cds.connect.to('db'); + const spy = vi.spyOn(db, 'run').mockRejectedValueOnce(new Error('boom')); + await refreshFeatureFlags(); + spy.mockRestore(); + expect(isFlagEnabled(METRICS)).toBe(true); // default ON survives the outage + expect(isFlagEnabled(PAGERANK)).toBe(false); // default OFF survives the outage + }); + + it('unknown flag key returns false (defensive)', () => { + expect(isFlagEnabled('NOT_A_REAL_FLAG')).toBe(false); + }); + + it('ensureFeatureFlagDefaults() seeds every absent flag to its declared default', async () => { + const seeded = await ensureFeatureFlagDefaults(); + expect(seeded.length).toBe(14); + await refreshFeatureFlags(); + expect(isFlagEnabled(METRICS)).toBe(true); + expect(isFlagEnabled(MCP_AUTH)).toBe(true); + expect(isFlagEnabled(PAGERANK)).toBe(false); + expect(isFlagEnabled(FRESHNESS)).toBe(false); + // The rows now physically carry the declared default strings. + const metricsRow = await SELECT.one.from(ImsConfig).where({ key: imsKey(METRICS) }); + expect(metricsRow.value).toBe('true'); + const prRow = await SELECT.one.from(ImsConfig).where({ key: imsKey(PAGERANK) }); + expect(prRow.value).toBe('false'); + }); + + it('ensureFeatureFlagDefaults() leaves an admin-set value untouched (override survives deploy)', async () => { + await setKey(METRICS, 'false'); // admin disabled the default-ON kill switch + const seeded = await ensureFeatureFlagDefaults(); + expect(seeded).not.toContain(imsKey(METRICS)); // present → not re-seeded + await refreshFeatureFlags(); + expect(isFlagEnabled(METRICS)).toBe(false); // override preserved + }); + + it('ensureFeatureFlagDefaults() is idempotent (no duplicate rows on re-run)', async () => { + await ensureFeatureFlagDefaults(); + const second = await ensureFeatureFlagDefaults(); + expect(second).toEqual([]); + const keys = managedFlagKeys().map(imsKey); + const rows = await SELECT.from(ImsConfig).where({ key: { in: keys } }); + expect(rows.length).toBe(14); + }); +}); diff --git a/test/unit/db/admin-managed-seed-csvs.test.js b/test/unit/db/admin-managed-seed-csvs.test.js index 30b6b5438..c82d7c88b 100644 --- a/test/unit/db/admin-managed-seed-csvs.test.js +++ b/test/unit/db/admin-managed-seed-csvs.test.js @@ -17,12 +17,17 @@ const DB_DATA = join(import.meta.dirname, '../../../db/data'); const TEST_DATA = join(import.meta.dirname, '../../../test/data'); const ADMIN_MANAGED = [ - 'com.sap.developers.ims-HomepageShelves.csv', 'com.sap.developers.ims-HomepageConfig.csv', 'com.sap.developers.ims-CommunityBlogSources.csv', 'com.sap.developers.ims-ImsConfig.csv', ]; +// HomepageShelves no longer has a seed CSV at all — it is seeded idempotently +// (insert-if-missing on verb+url) at boot by srv/lib/homepage/seed-homepage-shelves.js +// from the inline canonical defaults (srv/lib/homepage/homepage-shelves-defaults.js). +// The CSV was retired so nothing can ever full-replace the admin-managed table. +const RETIRED_SHELVES_CSV = 'com.sap.developers.ims-HomepageShelves.csv'; + describe('admin-managed seed CSVs stay out of the production seed folder', () => { for (const csv of ADMIN_MANAGED) { it(`${csv} is NOT in db/data (would re-seed on deploy and wipe admin edits)`, () => { @@ -33,6 +38,13 @@ describe('admin-managed seed CSVs stay out of the production seed folder', () => }); } + it(`${RETIRED_SHELVES_CSV} is retired — absent from BOTH db/data and test/data`, () => { + // Seeded at boot (insert-if-missing), never from a CSV → an HDI redeploy + // can never full-replace the admin-managed HomepageShelves table. + expect(existsSync(join(DB_DATA, RETIRED_SHELVES_CSV))).toBe(false); + expect(existsSync(join(TEST_DATA, RETIRED_SHELVES_CSV))).toBe(false); + }); + it('Categories seed CSV header omits the editable seedDescription column', () => { // seedDescription is authored/edited at runtime (Categories UPDATE after-hook // → category-seed-embeddings). Keeping it in the seed CSV would let a diff --git a/test/unit/feature-flags-registry.test.js b/test/unit/feature-flags-registry.test.js index 084b6f81b..a6325d1fc 100644 --- a/test/unit/feature-flags-registry.test.js +++ b/test/unit/feature-flags-registry.test.js @@ -34,7 +34,7 @@ describe('feature-flag registry shape', () => { }); describe('resolveFeatureFlags precedence + polarity', () => { - it('env true-enables flag reads on when env=true, default when unset', async () => { + it('db flag (KG_PAGERANK_ENABLED, #2060) reads its declared default when no ImsConfig row exists', async () => { vi.resetModules(); // KG resolvers hit cds; stub them so the module import is side-effect free. vi.doMock('../../srv/lib/runtime-config/kg-settings.js', () => ({ @@ -43,21 +43,18 @@ describe('resolveFeatureFlags precedence + polarity', () => { vi.doMock('../../srv/lib/runtime-config/ui-events-settings.js', () => ({ resolveUiEventsSettings: async () => ({ enabled: false }), })); + // Empty entities → ImsConfig entity is undefined → the db flag falls back to + // its declared registry default (false). An env var must NOT influence it. vi.doMock('@sap/cds', () => ({ default: { entities: () => ({}), log: () => ({ warn() {}, info() {}, error() {}, debug() {} }) } })); const { resolveFeatureFlags } = await import('../../srv/lib/feature-flags/resolve.js'); - process.env.KG_PAGERANK_ENABLED = 'true'; + process.env.KG_PAGERANK_ENABLED = 'true'; // legacy env var — now inert const rows = await resolveFeatureFlags(); const pr = rows.find((r) => r.key === 'KG_PAGERANK_ENABLED'); - expect(pr.enabled).toBe(true); - expect(pr.winningLayer).toBe('env'); - expect(pr.rawEnvValue).toBe('true'); - + expect(pr.kind).toBe('db'); + expect(pr.enabled).toBe(false); + expect(pr.winningLayer).toBe('default'); delete process.env.KG_PAGERANK_ENABLED; - const rows2 = await resolveFeatureFlags(); - const pr2 = rows2.find((r) => r.key === 'KG_PAGERANK_ENABLED'); - expect(pr2.enabled).toBe(false); - expect(pr2.winningLayer).toBe('default'); }); it('numeric chat db-setting falls back to env when the column is unset (#1171)', async () => { @@ -131,10 +128,13 @@ const ENV_IGNORE = new Set([ 'KG_MERGE_SIM_THRESHOLD', // similarity tuning 'KG_MERGE_SIM_THRESHOLD_EXTRACT', // similarity tuning 'KG_EXTRACT_BUILD_CAP', // batch-size tuning - // NOTE: on-by-default kill switches (METRICS_ENABLED, MCP_*_ENABLED, + // NOTE (#2060): the on-by-default kill switches (METRICS_ENABLED, MCP_*_ENABLED, // KG_RETIRE_ORPHANS_ENABLED, KG_STEP_SLICER_ENABLED, - // COMMUNITY_BLOGS_CLASSIFIER_ENABLED, HOMEPAGE_NEWS_RELEVANCE_ENABLED) - // ARE registered in registry.js — do NOT ignore them here. + // COMMUNITY_BLOGS_CLASSIFIER_ENABLED, HOMEPAGE_NEWS_RELEVANCE_ENABLED) plus + // KG_PAGERANK_ENABLED, KG_PATH_V2_ENABLED and FRESHNESS_SCAN_ENABLED were + // migrated from process.env to ImsConfig (kind:'db'). They no longer carry an + // envVar and are no longer read via process.env, so they neither appear in the + // scan below nor need an ignore entry here. ]); function walkJs(dir, acc = []) { diff --git a/test/unit/freshness-prompt-guard.test.js b/test/unit/freshness-prompt-guard.test.js index 78cb2141a..ca557e9cf 100644 --- a/test/unit/freshness-prompt-guard.test.js +++ b/test/unit/freshness-prompt-guard.test.js @@ -30,8 +30,14 @@ describe('SYSTEM_PROMPT context + SAP guidance', () => { expect(SYSTEM_PROMPT).toMatch(/pin/i); }); - it('biases toward precision: omit speculative findings, report each issue once', () => { - expect(SYSTEM_PROMPT).toMatch(/prefer reporting nothing|omit it/i); + it('calibrates precision by severity: strict High/Medium, advisory Low, each issue once', () => { + // High/Medium held to a strict, confidence-first bar... + expect(SYSTEM_PROMPT).toMatch(/high and medium/i); + expect(SYSTEM_PROMPT).toMatch(/prefer omitting|speculative/i); + // ...but Low is explicitly advisory so the feature surfaces cosmetic/dated-style + // staleness instead of defaulting to an empty report (regression fix). + expect(SYSTEM_PROMPT).toMatch(/advisory/i); + expect(SYSTEM_PROMPT).toMatch(/do not default to reporting nothing/i); expect(SYSTEM_PROMPT).toMatch(/once/i); expect(SYSTEM_PROMPT).toMatch(/author/i); }); diff --git a/test/unit/freshness-scan-job.test.js b/test/unit/freshness-scan-job.test.js index 7d9c2c996..d6d8e6132 100644 --- a/test/unit/freshness-scan-job.test.js +++ b/test/unit/freshness-scan-job.test.js @@ -1,6 +1,7 @@ // test/unit/freshness-scan-job.test.js import { describe, it, expect, beforeAll, afterEach } from 'vitest'; import cds from '@sap/cds'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; // Bootstrap: same pattern as other freshness unit tests. cds.test('serve', '--project', '.', '--in-memory'); @@ -9,20 +10,20 @@ describe('runFreshnessScan', () => { let db; beforeAll(async () => { db = await cds.connect.to('db'); }); afterEach(() => { - delete process.env.FRESHNESS_SCAN_ENABLED; + __resetFlagsForTest(); delete globalThis.__FRESHNESS_DETECT_IMPL__; delete globalThis.__FRESHNESS_TEST_IMPL__; }); it('self-skips when the flag is off', async () => { - delete process.env.FRESHNESS_SCAN_ENABLED; + __setFlagForTest('FRESHNESS_SCAN_ENABLED', false); const { runFreshnessScan } = await import('../../srv/jobs/freshness-scan-job.js'); const res = await runFreshnessScan('log'); expect(res.skipped).toBe(true); }); it('scans tutorials when enabled', async () => { - process.env.FRESHNESS_SCAN_ENABLED = 'true'; + __setFlagForTest('FRESHNESS_SCAN_ENABLED', true); // Full-stack hook: bypasses ALL I/O (grounding + LLM + ContentFiles read). // Returns the shape detectFreshness() resolves with so no ContentFiles seeding needed. globalThis.__FRESHNESS_DETECT_IMPL__ = async () => ({ model: 'm', costCents: 0, findings: [] }); diff --git a/test/unit/homepage-news-filter.test.js b/test/unit/homepage-news-filter.test.js index aa1663b9e..b4125e2f1 100644 --- a/test/unit/homepage-news-filter.test.js +++ b/test/unit/homepage-news-filter.test.js @@ -16,6 +16,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import cds from '@sap/cds'; import { _resetForTests as resetRssFetcherCache } from '../../srv/lib/homepage-rss-fetcher.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; cds.test('serve', '--project', '.', '--in-memory'); @@ -50,11 +51,11 @@ describe('homepage news() with #1034 filter', () => { await db.run(UPDATE('com.sap.developers.ims.HomepageConfig').set({ newsRelevanceEnabled: false })); const mod = await import('../../srv/homepage-service.js'); mod._resetForTests(); - delete process.env.HOMEPAGE_NEWS_RELEVANCE_ENABLED; + __resetFlagsForTest(); }); afterEach(() => { - delete process.env.HOMEPAGE_NEWS_RELEVANCE_ENABLED; + __resetFlagsForTest(); delete process.env.RSS_TRANSPORT; }); @@ -122,8 +123,8 @@ describe('homepage news() with #1034 filter', () => { expect(r).toEqual([]); }); - it('env HOMEPAGE_NEWS_RELEVANCE_ENABLED=false dominates HomepageConfig=true', async () => { - process.env.HOMEPAGE_NEWS_RELEVANCE_ENABLED = 'false'; + it('feature flag HOMEPAGE_NEWS_RELEVANCE_ENABLED=false dominates HomepageConfig=true', async () => { + __setFlagForTest('HOMEPAGE_NEWS_RELEVANCE_ENABLED', false); await db.run(UPDATE('com.sap.developers.ims.HomepageConfig').set({ newsRelevanceEnabled: true })); vi.stubGlobal('fetch', vi.fn(async () => new Response(FAKE_ENV_RSS_XML, { status: 200 }))); const r = await srv.send({ event: 'news' }); diff --git a/test/unit/homepage/homepage-shelves-defaults.test.js b/test/unit/homepage/homepage-shelves-defaults.test.js new file mode 100644 index 000000000..05a3a11d4 --- /dev/null +++ b/test/unit/homepage/homepage-shelves-defaults.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { HOMEPAGE_SHELVES_DEFAULTS } from '../../../srv/lib/homepage/homepage-shelves-defaults.js'; +import { validateTags } from '../../../srv/lib/homepage/persona-tag-validator.js'; + +// Guards the inline canonical baseline that replaced the retired seed CSV + +// third-party staging JSON. No DB required — pure data validation, mirroring +// the checks the old scripts/__tests__/seed-thirdparty-data test performed. +const VERBS = new Set(['LEARN', 'BUILD', 'INTEGRATE', 'MODEL', 'OPERATE', 'AI', 'CONNECT']); +const SHELVES = new Set(['START_HERE', 'REFERENCE', 'TOOLS', 'KEEP_CURRENT']); +const BADGES = new Set(['NEW', 'UPDATED', 'HIDDEN_GEM', 'THIRD_PARTY']); + +describe('HOMEPAGE_SHELVES_DEFAULTS (canonical baseline)', () => { + it('every row has the mandatory fields with correct primitive types', () => { + for (const r of HOMEPAGE_SHELVES_DEFAULTS) { + expect(typeof r.verb, `verb for ${r.title}`).toBe('string'); + expect(typeof r.url, `url for ${r.title}`).toBe('string'); + expect(typeof r.title, `title for ${r.url}`).toBe('string'); + expect(r.title.length).toBeGreaterThan(0); + expect(typeof r.sortOrder, `sortOrder for ${r.title}`).toBe('number'); + expect(typeof r.isActive, `isActive for ${r.title}`).toBe('boolean'); + expect(typeof r.isExternal, `isExternal for ${r.title}`).toBe('boolean'); + } + }); + + it('every verb / shelf / badge value is a valid enum member', () => { + for (const r of HOMEPAGE_SHELVES_DEFAULTS) { + expect(VERBS, `verb ${r.verb}`).toContain(r.verb); + expect(SHELVES, `shelf ${r.shelf}`).toContain(r.shelf); + if (r.badge != null) expect(BADGES, `badge ${r.badge}`).toContain(r.badge); + } + }); + + it('(verb,url) is unique across all rows (matches @assert.unique.verbUrl)', () => { + const seen = new Set(); + for (const r of HOMEPAGE_SHELVES_DEFAULTS) { + const key = `${r.verb}|${r.url}`; + expect(seen.has(key), `duplicate (verb,url): ${key}`).toBe(false); + seen.add(key); + } + }); + + it('every personaTag is in the known vocabulary', () => { + for (const r of HOMEPAGE_SHELVES_DEFAULTS) { + if (r.personaTags?.length) { + const res = validateTags(r.personaTags); + expect(res.ok, `bad personaTags on ${r.title}: ${JSON.stringify(res.invalid)}`).toBe(true); + } + } + }); + + it('covers all 7 verbs and includes the curated third-party links', () => { + const verbs = new Set(HOMEPAGE_SHELVES_DEFAULTS.map((r) => r.verb)); + for (const v of VERBS) expect(verbs, `missing verb ${v}`).toContain(v); + const titles = HOMEPAGE_SHELVES_DEFAULTS.map((r) => r.title).join(' | '); + for (const t of ['Dremio', 'Reltio', 'Prior Labs', 'n8n']) { + expect(titles, `missing third-party link ${t}`).toContain(t); + } + }); +}); diff --git a/test/unit/homepage/seed-homepage-shelves.test.js b/test/unit/homepage/seed-homepage-shelves.test.js new file mode 100644 index 000000000..2f80bf68c --- /dev/null +++ b/test/unit/homepage/seed-homepage-shelves.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { seedHomepageShelves } from '../../../srv/lib/homepage/seed-homepage-shelves.js'; +import { HOMEPAGE_SHELVES_DEFAULTS } from '../../../srv/lib/homepage/homepage-shelves-defaults.js'; + +cds.test('serve', '--project', '.', '--in-memory'); + +describe('seedHomepageShelves (idempotent, non-destructive boot seed)', () => { + let db; + beforeAll(async () => { db = await cds.connect.to('db'); }); + + it('boot (cds.on served) seeded the full baseline incl. third-party links', async () => { + const { HomepageShelves } = cds.entities('com.sap.developers.ims'); + const rows = await db.run(SELECT.from(HomepageShelves)); + expect(rows.length).toBe(HOMEPAGE_SHELVES_DEFAULTS.length); + const titles = rows.map((r) => r.title).join(' | '); + for (const t of ['Dremio', 'Reltio', 'Prior Labs', 'n8n']) { + expect(titles).toContain(t); + } + }); + + it('re-running the seed inserts nothing (idempotent on verb+url)', async () => { + const res = await seedHomepageShelves(db); + expect(res.inserted).toBe(0); + expect(res.total).toBe(HOMEPAGE_SHELVES_DEFAULTS.length); + }); + + it('never overwrites an existing (admin-edited) row', async () => { + const { HomepageShelves } = cds.entities('com.sap.developers.ims'); + const target = HOMEPAGE_SHELVES_DEFAULTS[0]; + const edited = 'ADMIN EDITED TITLE — do not clobber'; + await db.run( + UPDATE(HomepageShelves).set({ title: edited }).where({ verb: target.verb, url: target.url }), + ); + const before = (await db.run(SELECT.from(HomepageShelves))).length; + + await seedHomepageShelves(db); + + const row = await db.run( + SELECT.one.from(HomepageShelves).where({ verb: target.verb, url: target.url }), + ); + expect(row.title).toBe(edited); // untouched + const after = (await db.run(SELECT.from(HomepageShelves))).length; + expect(after).toBe(before); // no duplicate inserted + }); + + it('re-inserts a baseline row that was deleted (self-heals)', async () => { + const { HomepageShelves } = cds.entities('com.sap.developers.ims'); + const victim = HOMEPAGE_SHELVES_DEFAULTS[1]; + await db.run(DELETE.from(HomepageShelves).where({ verb: victim.verb, url: victim.url })); + expect( + await db.run(SELECT.one.from(HomepageShelves).where({ verb: victim.verb, url: victim.url })), + ).toBeUndefined(); + + const res = await seedHomepageShelves(db); + expect(res.inserted).toBe(1); + expect( + await db.run(SELECT.one.from(HomepageShelves).where({ verb: victim.verb, url: victim.url })), + ).toBeTruthy(); + }); +}); diff --git a/test/unit/kg-path-between-handler.test.js b/test/unit/kg-path-between-handler.test.js index efdae7726..c29a1f88b 100644 --- a/test/unit/kg-path-between-handler.test.js +++ b/test/unit/kg-path-between-handler.test.js @@ -44,6 +44,9 @@ const { getConceptsForUser } = await import('../../srv/lib/kg/concepts-for-user. const { kgPathV2 } = await import('../../srv/lib/kg-path-v2-client.js') const { findLearningPathHandler, FIND_LEARNING_PATH_TOOL } = await import('../../srv/lib/kg/joule-tool-find-path.js') +// KG_PATH_V2_ENABLED migrated env → DB (ImsConfig key flag.kg.pathV2, #2060): +// the v2 branch is gated by isFlagEnabled(), so stubEnv no longer toggles it. +const { __setFlagForTest, __resetFlagsForTest } = await import('../../srv/lib/feature-flags/db-flags.js') // --------------------------------------------------------------------------- // Test helpers @@ -515,6 +518,7 @@ describe('findLearningPathHandler — KG_PATH_V2 engine (issue #1253)', () => { }) afterEach(() => { vi.unstubAllEnvs() + __resetFlagsForTest() }) // db.run dispatcher for the v2 branch: TUTORIALS hydration + CONCEPTS names. @@ -530,7 +534,7 @@ describe('findLearningPathHandler — KG_PATH_V2 engine (issue #1253)', () => { } it('collapsed [A,B] path: 2 steps, B last, bridge lists concept names', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([ { pathRank: 1, hopCount: 2, vertices: ['tutorial:abap-create-basic-app', 'concept:abap-cloud', 'concept:rap-bo', 'tutorial:abap-create-project'] }, ]) @@ -558,7 +562,7 @@ describe('findLearningPathHandler — KG_PATH_V2 engine (issue #1253)', () => { }) it('intermediate tutorial on path renders as an ordered middle step', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([ { pathRank: 1, hopCount: 3, vertices: ['tutorial:a', 'concept:x', 'tutorial:m', 'concept:y', 'tutorial:b'] }, ]) @@ -578,7 +582,7 @@ describe('findLearningPathHandler — KG_PATH_V2 engine (issue #1253)', () => { }) it('direct tutorial↔tutorial path (no interior concepts) → Directly connected', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([ { pathRank: 1, hopCount: 1, vertices: ['tutorial:a', 'tutorial:b'] }, ]) @@ -595,7 +599,7 @@ describe('findLearningPathHandler — KG_PATH_V2 engine (issue #1253)', () => { }) it('emits engine:v2 in path_returned telemetry', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([ { pathRank: 1, hopCount: 1, vertices: ['tutorial:a', 'tutorial:b'] }, ]) @@ -612,7 +616,7 @@ describe('findLearningPathHandler — KG_PATH_V2 engine (issue #1253)', () => { }) it('v2 empty → falls through to v1 neighbor render (engine:v1)', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([]) kgQuery.mockResolvedValue({ response: buildJsonResponse([{ slug: 'cap-getting-started', pathType: 'SHARED_CONCEPT', rank: 3 }]) }) // v1 branch uses makeDb() dispatcher (TASKRECORDS/TUTORIALCONCEPTLINKS/TUTORIALS). diff --git a/test/unit/kg-path-v2-or-v1.test.js b/test/unit/kg-path-v2-or-v1.test.js index 57dfe58ae..b639d32f4 100644 --- a/test/unit/kg-path-v2-or-v1.test.js +++ b/test/unit/kg-path-v2-or-v1.test.js @@ -18,6 +18,9 @@ vi.mock('../../srv/lib/kg-sparql-client.js', () => ({ const { kgPathV2 } = await import('../../srv/lib/kg-path-v2-client.js') const { kgQuery } = await import('../../srv/lib/kg-sparql-client.js') const { findPathV2OrV1 } = await import('../../srv/lib/kg-path.js') +// KG_PATH_V2_ENABLED migrated env → DB (ImsConfig key flag.kg.pathV2, #2060): +// kg-path.js now reads it via isFlagEnabled(), so stubEnv no longer toggles it. +const { __setFlagForTest, __resetFlagsForTest } = await import('../../srv/lib/feature-flags/db-flags.js') const PFX = 'https://developers.sap.com/kg/tutorial/' function v1Json(slugs) { @@ -41,11 +44,12 @@ beforeEach(() => { }) afterEach(() => { vi.unstubAllEnvs() + __resetFlagsForTest() }) describe('findPathV2OrV1 engine selection', () => { it('flag on + v2 non-empty → returns engine v2 with vertices', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([ { pathRank: 1, hopCount: 2, vertices: [`tutorial:a`, `concept:x`, `tutorial:b`] }, ]) @@ -57,7 +61,7 @@ describe('findPathV2OrV1 engine selection', () => { }) it('flag on + v2 empty → falls through to v1', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockResolvedValue([]) kgQuery.mockResolvedValue({ response: v1Json(['b']) }) const out = await findPathV2OrV1({ db, fromSlug: 'a', toSlug: 'b' }) @@ -66,7 +70,7 @@ describe('findPathV2OrV1 engine selection', () => { }) it('flag on + v2 throws → falls through to v1 (fail-open)', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'true') + __setFlagForTest('KG_PATH_V2_ENABLED', true) kgPathV2.mockRejectedValue(Object.assign(new Error('boom'), { code: 'ETIMEDOUT' })) kgQuery.mockResolvedValue({ response: v1Json(['b']) }) const out = await findPathV2OrV1({ db, fromSlug: 'a', toSlug: 'b' }) @@ -75,7 +79,7 @@ describe('findPathV2OrV1 engine selection', () => { }) it('flag off → v1 directly, kgPathV2 never called', async () => { - vi.stubEnv('KG_PATH_V2_ENABLED', 'false') + __setFlagForTest('KG_PATH_V2_ENABLED', false) kgQuery.mockResolvedValue({ response: v1Json(['b']) }) const out = await findPathV2OrV1({ db, fromSlug: 'a', toSlug: 'b' }) expect(out.engine).toBe('v1') diff --git a/test/unit/kg-retire-orphans-job.test.js b/test/unit/kg-retire-orphans-job.test.js index 1444b5156..59439221a 100644 --- a/test/unit/kg-retire-orphans-job.test.js +++ b/test/unit/kg-retire-orphans-job.test.js @@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import cds from '@sap/cds'; import { runRetireOrphans, readAgeDays, isEnabled } from '../../srv/jobs/kg-retire-orphans-job.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; const NS = 'com.sap.developers.ims'; @@ -18,7 +19,7 @@ describe('runRetireOrphans (#1115)', () => { await DELETE.from(TutorialConceptLinks); await DELETE.from(ConceptEdges); await DELETE.from(Concepts); - delete process.env.KG_RETIRE_ORPHANS_ENABLED; + __resetFlagsForTest(); delete process.env.KG_RETIRE_ORPHANS_AGE_DAYS; }); @@ -73,13 +74,13 @@ describe('runRetireOrphans (#1115)', () => { expect(res.retired).toBe(0); }); - it('honors KG_RETIRE_ORPHANS_ENABLED=false', async () => { + it('honors the KG_RETIRE_ORPHANS_ENABLED flag = false', async () => { const { Concepts } = cds.entities(NS); await INSERT.into(Concepts).entries({ ID: 'o0000000-0000-0000-0000-000000000009', slug: 'skip', name: 'Skip', status: 'ACTIVE', firstSeenAt: daysAgoIso(20), }); - process.env.KG_RETIRE_ORPHANS_ENABLED = 'false'; + __setFlagForTest('KG_RETIRE_ORPHANS_ENABLED', false); const res = await runRetireOrphans(); expect(res.reason).toBe('disabled'); expect(res.retired).toBe(0); diff --git a/test/unit/mcp-auth-challenge.test.js b/test/unit/mcp-auth-challenge.test.js new file mode 100644 index 000000000..3c53ae2cf --- /dev/null +++ b/test/unit/mcp-auth-challenge.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const XSUAA = { xsuaa: [{ credentials: { + url: 'https://tenant.authentication.eu10-005.hana.ondemand.com', + xsappname: 'tutorials!t676072', +} }] }; +function mockRes() { + return { statusCode: null, headers: {}, body: null, + setHeader(k, v) { this.headers[k] = v; }, + writeHead(s, h) { this.statusCode = s; Object.assign(this.headers, h || {}); return this; }, + end(p) { this.body = p; return this; } }; +} +let mod, prevVcap; +beforeAll(() => { prevVcap = process.env.VCAP_SERVICES; process.env.VCAP_SERVICES = JSON.stringify(XSUAA); + mod = require('../../approuter/lib/mcp-auth-challenge.js'); }); +afterAll(() => { if (prevVcap === undefined) delete process.env.VCAP_SERVICES; else process.env.VCAP_SERVICES = prevVcap; }); + +describe('mcp-auth-challenge', () => { + it('401s with a resource_metadata pointer when no bearer on /mcp-auth', () => { + const { mcpAuthChallengeHandler } = mod; + const res = mockRes(); let nexted = false; + mcpAuthChallengeHandler( + { method: 'POST', url: '/mcp-auth/api', + headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'developers.sap.com' } }, + res, () => { nexted = true; }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(401); + expect(res.headers['WWW-Authenticate']).toBe( + 'Bearer resource_metadata="https://developers.sap.com/.well-known/oauth-protected-resource", scope="tutorials!t676072.Tutorial.MCP"'); + }); + + it('passes through when an Authorization bearer is present', () => { + const { mcpAuthChallengeHandler } = mod; + const res = mockRes(); let nexted = false; + mcpAuthChallengeHandler( + { method: 'POST', url: '/mcp-auth/api', headers: { authorization: 'Bearer abc' } }, + res, () => { nexted = true; }); + expect(nexted).toBe(true); + expect(res.statusCode).toBeNull(); + }); + + it('ignores unrelated paths', () => { + const { mcpAuthChallengeHandler } = mod; + const res = mockRes(); let nexted = false; + mcpAuthChallengeHandler({ method: 'GET', url: '/tutorials/foo', headers: {} }, res, () => { nexted = true; }); + expect(nexted).toBe(true); + }); +}); diff --git a/test/unit/mcp-compose-router.test.js b/test/unit/mcp-compose-router.test.js index a961e0b36..212819d90 100644 --- a/test/unit/mcp-compose-router.test.js +++ b/test/unit/mcp-compose-router.test.js @@ -6,11 +6,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; // We unit-test the capability wiring by invoking the exported buildServer() with fakes, // NOT by standing up HTTP (that's the contract/hybrid layer's job). import { buildServer, flags } from '../../srv/lib/mcp-compose-router.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; describe('mcp-compose-router capability wiring', () => { beforeEach(() => { - delete process.env.MCP_RESOURCES_ENABLED; - delete process.env.MCP_PROMPTS_ENABLED; + __resetFlagsForTest(); }); it('flags default all enabled', () => { @@ -36,8 +36,8 @@ describe('mcp-compose-router capability wiring', () => { expect(server.registerResource).toHaveBeenCalled(); }); - it('omits resources capability when MCP_RESOURCES_ENABLED=false', async () => { - process.env.MCP_RESOURCES_ENABLED = 'false'; + it('omits resources capability when the MCP_RESOURCES_ENABLED flag is off', async () => { + __setFlagForTest('MCP_RESOURCES_ENABLED', false); const caps = {}; const server = { registerResource: vi.fn(), server: { registerCapabilities: (c) => Object.assign(caps, c), setRequestHandler: vi.fn() } }; await buildServer(server, { name: 'X', definition: {} }, { entities: {}, actions: {} }, { diff --git a/test/unit/mcp-progress-tools.test.js b/test/unit/mcp-progress-tools.test.js index b3a77a339..3c9f0f0cc 100644 --- a/test/unit/mcp-progress-tools.test.js +++ b/test/unit/mcp-progress-tools.test.js @@ -19,6 +19,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import cds from '@sap/cds'; import { gzipSync } from 'node:zlib'; +import { __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; const project = cds.test('serve', '--project', '.', '--in-memory'); @@ -152,8 +153,9 @@ describe('DeveloperService authenticated MCP read tools', () => { version: 9001, slug: 'tut-a', content: gzBuf, mimeType: 'text/html' }); - // Must set KG_STEP_SLICER_ENABLED to not 'false' (default is enabled) - delete process.env.KG_STEP_SLICER_ENABLED; + // KG_STEP_SLICER_ENABLED defaults enabled (ImsConfig flag.kg.stepSlicer); + // reset the flag cache so the step slicer is on for this read. + __resetFlagsForTest(); const { data } = await project.get( `/api/get_tutorial_step(slug='tut-a',stepNumber=1)`, diff --git a/test/unit/metrics-db-wrap.test.js b/test/unit/metrics-db-wrap.test.js index e40033847..197cbb79c 100644 --- a/test/unit/metrics-db-wrap.test.js +++ b/test/unit/metrics-db-wrap.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as metrics from '../../srv/lib/metrics.js'; import { installDbWrap, _resetForTest as _resetWrap } from '../../srv/lib/metrics-db-wrap.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; // Build a mock `cds` module surface: cds.db.run + cds.db.tx with runtime-like // semantics — db.tx(fn) invokes fn with a tx object exposing tx.run(...) and @@ -22,19 +23,18 @@ function makeMockCds({ runImpl, txRunImpl } = {}) { } describe('metrics-db-wrap installDbWrap (#909)', () => { - const originalMetricsEnabled = process.env.METRICS_ENABLED; const originalDbWrap = process.env.METRICS_DB_WRAP; beforeEach(() => { _resetWrap(); metrics._resetForTest(); - delete process.env.METRICS_ENABLED; + __resetFlagsForTest(); process.env.METRICS_DB_WRAP = 'true'; }); afterEach(() => { _resetWrap(); - process.env.METRICS_ENABLED = originalMetricsEnabled; + __resetFlagsForTest(); process.env.METRICS_DB_WRAP = originalDbWrap; }); @@ -46,8 +46,8 @@ describe('metrics-db-wrap installDbWrap (#909)', () => { expect(cds.db.run).toBe(originalRun); }); - it('does NOT install when METRICS_ENABLED === "false" (kill-switch)', () => { - process.env.METRICS_ENABLED = 'false'; + it('does NOT install when the METRICS_ENABLED flag is off (kill-switch)', () => { + __setFlagForTest('METRICS_ENABLED', false); process.env.METRICS_DB_WRAP = 'true'; const cds = makeMockCds(); const originalRun = cds.db.run; diff --git a/test/unit/metrics-rollup-job.test.js b/test/unit/metrics-rollup-job.test.js index 91a85a425..ee6e0a053 100644 --- a/test/unit/metrics-rollup-job.test.js +++ b/test/unit/metrics-rollup-job.test.js @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as metrics from '../../srv/lib/metrics.js'; +import { __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; // Minimal cds stub — the job uses cds.log and cds.entities(NAMESPACE). vi.mock('@sap/cds', () => { @@ -23,7 +24,7 @@ function installInsert(behavior) { describe('#1257 rollup per-row fallback', () => { beforeEach(() => { metrics._resetForTest(); - delete process.env.METRICS_ENABLED; + __resetFlagsForTest(); metrics.counter('a.b.c', 1); metrics.counter('d.e.f', 1); }); diff --git a/test/unit/srv/kg-path-v2-handler-flag.test.js b/test/unit/srv/kg-path-v2-handler-flag.test.js index 259bf58f9..ec50b4cad 100644 --- a/test/unit/srv/kg-path-v2-handler-flag.test.js +++ b/test/unit/srv/kg-path-v2-handler-flag.test.js @@ -13,6 +13,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import cds from '@sap/cds'; +import { __setFlagForTest, __resetFlagsForTest } from '../../../srv/lib/feature-flags/db-flags.js'; // Enable the KG service surface (bypasses the this.before('*') 503 gate). // Must be set BEFORE cds.test() boots so resolveKnowledgeGraphSettings @@ -45,7 +46,7 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.KG_PATH_V2_ENABLED; + __resetFlagsForTest(); }); afterAll(() => { @@ -58,7 +59,7 @@ const CALL = `/graph/pathBetween(fromSlug='a',toSlug='b')`; describe('pathBetween handler — flag off', () => { it('v2 wrapper is never called; v1 (kgQuery) runs with IRI-shaped params', async () => { - delete process.env.KG_PATH_V2_ENABLED; + __setFlagForTest('KG_PATH_V2_ENABLED', false); // v1 SPARQL result — an empty PATH_BETWEEN JSON body. kgQueryMock.mockResolvedValue({ response: JSON.stringify({ results: { bindings: [] } }), @@ -84,7 +85,7 @@ describe('pathBetween handler — flag off', () => { }); describe('pathBetween handler — flag on', () => { - beforeEach(() => { process.env.KG_PATH_V2_ENABLED = 'true'; }); + beforeEach(() => { __setFlagForTest('KG_PATH_V2_ENABLED', true); }); it('v2 returns rows → response is v2-mapped, v1 not called', async () => { kgPathV2Mock.mockResolvedValue([ diff --git a/test/unit/well-known-mcp-manifest.test.js b/test/unit/well-known-mcp-manifest.test.js new file mode 100644 index 000000000..eeac91506 --- /dev/null +++ b/test/unit/well-known-mcp-manifest.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const XSUAA = { xsuaa: [{ credentials: { + url: 'https://tenant.authentication.eu10-005.hana.ondemand.com', + xsappname: 'tutorials!t676072', +} }] }; +function mockRes() { + return { statusCode: null, headers: null, body: null, + writeHead(s, h) { this.statusCode = s; this.headers = h; return this; }, + end(p) { this.body = p; return this; } }; +} +const REQ = { method: 'GET', url: '/.well-known/mcp.json', + headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'developers.sap.com' } }; + +let mod, prevVcap; +beforeAll(() => { prevVcap = process.env.VCAP_SERVICES; process.env.VCAP_SERVICES = JSON.stringify(XSUAA); + mod = require('../../approuter/lib/well-known-mcp-manifest.js'); }); +afterAll(() => { if (prevVcap === undefined) delete process.env.VCAP_SERVICES; else process.env.VCAP_SERVICES = prevVcap; }); + +describe('well-known-mcp-manifest', () => { + it('serves mcp.json with server list and qualified scope', () => { + const { mcpManifestHandler } = mod; + const res = mockRes(); + let nexted = false; + mcpManifestHandler(REQ, res, () => { nexted = true; }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(200); + expect(res.headers['Content-Type']).toMatch(/application\/json/); + const doc = JSON.parse(res.body); + expect(doc.servers.map(s => s.name)).toEqual(['search', 'homepage', 'graph', 'developer']); + const dev = doc.servers.find(s => s.name === 'developer'); + expect(dev.url).toBe('https://developers.sap.com/mcp-auth/api'); + expect(dev.scope).toBe('tutorials!t676072.Tutorial.MCP'); + expect(doc.authorization.protected_resource) + .toBe('https://developers.sap.com/.well-known/oauth-protected-resource'); + }); + + it('passes through non-matching paths and non-GET methods', () => { + const { mcpManifestHandler } = mod; + for (const req of [ + { method: 'GET', url: '/.well-known/other', headers: {} }, + { method: 'POST', url: '/.well-known/mcp.json', headers: {} }, + ]) { + const res = mockRes(); let nexted = false; + mcpManifestHandler(req, res, () => { nexted = true; }); + expect(nexted).toBe(true); + expect(res.statusCode).toBeNull(); + } + }); +}); diff --git a/test/unit/well-known-oauth.test.js b/test/unit/well-known-oauth.test.js index fc94adf48..321b8fbf5 100644 --- a/test/unit/well-known-oauth.test.js +++ b/test/unit/well-known-oauth.test.js @@ -182,3 +182,31 @@ describe('.well-known OAuth discovery — dynamic runtime middleware (#1105)', ( expect(res.statusCode).toBeNull(); }); }); + +describe('well-known-oauth: openid-configuration alias', () => { + const SAVED_VCAP = process.env.VCAP_SERVICES; + beforeEach(() => { + process.env.VCAP_SERVICES = JSON.stringify({ xsuaa: [{ credentials: { + url: 'https://tenant.authentication.eu10-005.hana.ondemand.com', + xsappname: 'tutorials!t676072', + } }] }); + }); + afterEach(() => { + if (SAVED_VCAP === undefined) delete process.env.VCAP_SERVICES; else process.env.VCAP_SERVICES = SAVED_VCAP; + }); + + it('serves openid-configuration with the same body as oauth-authorization-server', () => { + const { OPENID_CONFIG_PATH } = require('../../approuter/lib/well-known-oauth.js'); + expect(OPENID_CONFIG_PATH).toBe('/.well-known/openid-configuration'); + const res = mockRes(); + let nexted = false; + wellKnownOAuthHandler({ method: 'GET', url: OPENID_CONFIG_PATH, headers: { host: 'x.example' } }, res, () => { nexted = true; }); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(200); + expect(res.headers['Content-Type']).toBe('application/json'); + const doc = JSON.parse(res.body); + expect(doc).toEqual(authorizationServerMetadata( + 'https://tenant.authentication.eu10-005.hana.ondemand.com', resolveScope())); + expect(doc.code_challenge_methods_supported).toContain('S256'); + }); +});