From d443c70bb554c0bb03edca0533c282928a569256 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 10:34:06 -0400 Subject: [PATCH 01/46] docs(api): update MCP signed-in auth to IAS native-HTTP OAuth The /api-docs page documented the old XSUAA + mcp-remote bridge for the /mcp-auth/* tier. Production now fronts it with SAP Identity Authentication Service (IAS): OAuth 2.1 authorization-code + PKCE, public client (no secret), discovery via RFC 9728 protected-resource metadata. Native-HTTP-OAuth MCP clients point straight at the mount; no mcp-remote bridge needed. A2A remains XSUAA client-credentials (not migrated); its section is unchanged, tracked separately. --- hugo/content/api-docs/_index.md | 4 ++-- hugo/data/api_endpoints.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md index eebdf3ffa..f6d375437 100644 --- a/hugo/content/api-docs/_index.md +++ b/hugo/content/api-docs/_index.md @@ -107,10 +107,10 @@ Each CDS service is mounted separately under `/mcp/*` over the [Model Context Pr **Signed-in tools** (your progress, recommendations, marking steps done) live behind two authenticated tiers: -- **`/mcp-auth/*`** — OAuth 2.1 + PKCE. Requires the `Tutorial.MCP` scope (**Tutorials MCP Users** role collection). XSUAA has no dynamic client registration, so bridge through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) with the pre-registered public client. +- **`/mcp-auth/*`** — OAuth 2.1 authorization-code + PKCE against SAP Identity Authentication Service (IAS), as a **public client** (no secret). Point an MCP client with native HTTP OAuth straight at the mount — the authorization server, scopes, and PKCE method are advertised via [`/.well-known/oauth-protected-resource`](https://developers.sap.com/.well-known/oauth-protected-resource) and [`/.well-known/oauth-authorization-server`](https://developers.sap.com/.well-known/oauth-authorization-server), so no `mcp-remote` bridge is needed. In Claude Code: `"sap-developers-auth": { "type": "http", "url": "https://developers.sap.com/mcp-auth/api" }` — approve, and the browser sign-in completes the flow. - **`/mcp-pat/*`** — a **Personal Access Token** for headless / CI clients that can't do a browser flow. Mint one at [`/me/tokens/`](/me/tokens/) (self-service; requires the **Tutorials MCP Users** role collection), then send `Authorization: Bearer pat_...`. Scopes: `read` (read tools) or `write` (also allows `complete_step` / `reset_tutorial_progress`). The plaintext token is shown once. -Full connection walkthrough (Claude Desktop, Claude Code, `mcp-remote`, PATs, troubleshooting): [MCP Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/mcp-quickstart.md). Tool + parameter reference: [mcp-server.md](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/developers/reference/mcp-server.md). +Full connection walkthrough (Claude Desktop, Claude Code, native HTTP OAuth, PATs, troubleshooting): [MCP Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/mcp-quickstart.md). Tool + parameter reference: [mcp-server.md](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/developers/reference/mcp-server.md). ### Local `sap-devs` CLI MCP (over stdio) diff --git a/hugo/data/api_endpoints.yaml b/hugo/data/api_endpoints.yaml index ecb74ecb7..19d8342cc 100644 --- a/hugo/data/api_endpoints.yaml +++ b/hugo/data/api_endpoints.yaml @@ -116,8 +116,8 @@ sections: auth: "XSUAA" purpose: 'Joule chat streaming endpoint (Server-Sent Events).' - path: "/mcp-auth/*" - auth: "XSUAA" - purpose: 'Hosted MCP — OAuth/JWT tier (scope Tutorial.MCP). Forwards your JWT to the user-scoped mounts (/mcp/api DeveloperService, /mcp/homepage): your progress, missions, events, recommendations. Bridge via mcp-remote with the pre-registered client.' + auth: "IAS (OAuth2 + PKCE)" + purpose: 'Hosted MCP — OAuth tier (SAP IAS, authorization-code + PKCE, public client, no secret). Forwards your token to the user-scoped mounts (/mcp/api DeveloperService, /mcp/homepage): your progress, missions, events, recommendations. Point a native-HTTP-OAuth MCP client straight at the mount — no mcp-remote bridge.' driftMarkers: - "/mcp-auth/*" - "/mcp/api" From 0437ff102af8ac907fa45e38e3592c1a7da10c0a Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 11:02:40 -0400 Subject: [PATCH 02/46] fix(173): load os-toggle.ts on object-page layout so OS picker hydrates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The u1-object-page layout wired the OS picker markup and os-toggle.css but never loaded the compiled os-toggle.ts runtime. Result: no element ever got [data-os-options-hydrated], so the hide/show CSS stayed inert and every OS panel rendered stacked with a non-functional picker (visible on the feature cookbook tutorial, Step 1). Add a hasOsOptions-guarded js.Build + script include mirroring the existing tutorial.ts pattern and the picker's own guard. os-toggle.ts and its 16-test unit suite were already present and passing — this is pure wiring. Verified: scoped Hugo build of the feature-cookbook page now emits {{ end }} +{{/* Issue #173: load the OS-picker runtime only when the page has OS-flavored + option blocks (hasOsOptions=true) — matches the picker guard above. Without + this, os-toggle.ts never mounts, the picker stays inert, and every OS panel + renders stacked (the CSS hide/show rules are gated on [data-os-options-hydrated], + which only this script sets). */}} +{{ if and .Params.hasOsOptions (not site.Params.previewMode) }} +{{ $osToggle := resources.Get "js/os-toggle.ts" | js.Build (dict "minify" true) }} + +{{ end }} {{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }} {{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }} From 60c6a5bda89ca543a668085e67a07808e33509d2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 11:19:18 -0400 Subject: [PATCH 03/46] fix(identity): run identity-pin on the request tx to end SQLite deadlock (#2552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global identity-pin before('*') hook (#2571) called pinResolvedUser with the resolver's bare top-level SELECT/INSERT CDS QL globals, which bind to cds.db on a fresh ambient autocommit transaction. On single-connection in-memory SQLite (unit tests) that ambient tx deadlocks against a handler's own root cds.tx(...) — one connection, two contending root transactions — producing the 30s timeout in admin-featured-topics-crud.test.js seen on every CI run since PR #2571. HANA (pooled) does not deadlock, so it only surfaced in tests. - srv/server.js: pass cds.tx(req) to pinResolvedUser so the resolver joins the request's own transaction instead of opening a new ambient one. - packages/core/resolve-db-user.js: thread an optional db runner through pinResolvedUser -> resolveUser -> writeIdentityLink; (db ? db.run(stmt) : stmt) preserves the existing no-arg/mock behavior for all other callers. - srv/admin-service.js: recomputeFeaturedTopics + the FeaturedTopics after-write hook now join cds.tx(req) instead of opening a second root cds.tx(async tx => ...), the actual deadlock partner. The gitignored srv/lib/_shared/core.bundle.mjs is regenerated by the prebuild hook on build/deploy; no bundle commit needed. --- packages/core/resolve-db-user.js | 35 +++++++++++++++++++++----------- srv/admin-service.js | 10 ++++++--- srv/server.js | 6 +++++- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/core/resolve-db-user.js b/packages/core/resolve-db-user.js index 2a61e3582..64735e990 100644 --- a/packages/core/resolve-db-user.js +++ b/packages/core/resolve-db-user.js @@ -83,12 +83,14 @@ export function issuerSubjectFromUser(user) { * @param {string} user_ID — the Users.ID to link. * @param {{issuer:string, subject:string}} isu * @param {{provider?:string, email?:string, emailVerified?:boolean}} [meta] + * @param {object} [db] — optional tx/service runner (e.g. cds.tx(req)); when + * given, the INSERT runs on that transaction instead of a fresh ambient one. */ -export async function writeIdentityLink(user_ID, isu, meta = {}) { +export async function writeIdentityLink(user_ID, isu, meta = {}, db) { if (!user_ID || !isu) return; try { const { UserIdentities } = cds.entities('com.sap.developers.ims'); - await INSERT.into(UserIdentities).entries({ + const stmt = INSERT.into(UserIdentities).entries({ ID: cds.utils.uuid(), user_ID, issuer: isu.issuer, @@ -98,6 +100,7 @@ export async function writeIdentityLink(user_ID, isu, meta = {}) { emailVerified: meta.emailVerified ?? false, linkedAt: new Date().toISOString(), }); + await (db ? db.run(stmt) : stmt); } catch (err) { if (!/unique|duplicate/i.test(String(err?.message ?? ''))) throw err; } @@ -130,20 +133,25 @@ const SCIM_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ * Miss on all tiers → null (caller stays fail-closed). * * @param {object} user — CAP cds.context.user / req.user. + * @param {object} [db] — optional tx/service runner (e.g. cds.tx(req)); when + * given, all reads/writes run on that transaction instead of a fresh ambient + * one. Prevents single-connection SQLite deadlocks when a handler already + * holds the request tx (#2552). * @returns {Promise} the full Users row, or null. */ -export async function resolveUser(user) { +export async function resolveUser(user, db) { if (!user || !user.id || user.id === 'anonymous') return null; const { Users, UserIdentities } = cds.entities('com.sap.developers.ims'); + const run = (stmt) => (db ? db.run(stmt) : stmt); // ── Tier 1: (issuer, subject) link ────────────────────────────────────── const isu = issuerSubjectFromUser(user); if (isu) { - const link = await SELECT.one.from(UserIdentities) + const link = await run(SELECT.one.from(UserIdentities) .columns('user_ID') - .where({ issuer: isu.issuer, subject: isu.subject }); + .where({ issuer: isu.issuer, subject: isu.subject })); if (link?.user_ID) { - const row = await SELECT.one.from(Users).where({ ID: link.user_ID }); + const row = await run(SELECT.one.from(Users).where({ ID: link.user_ID })); if (row) return row; } } @@ -155,14 +163,14 @@ export async function resolveUser(user) { // canonical value as a Tier-2 key — a SCIM-UUID or email here is not a sapId. const canonicalSapId = sapId && !SCIM_UUID_RE.test(sapId) && !sapId.includes('@') ? sapId : null; if (canonicalSapId) { - const row = await SELECT.one.from(Users).where({ sapId: canonicalSapId }); + const row = await run(SELECT.one.from(Users).where({ sapId: canonicalSapId })); if (row) { if (isu) { await writeIdentityLink(row.ID, isu, { provider: providerFromIssuer(isu.issuer), email: user.authInfo?.token?.payload?.email ?? null, emailVerified: true, - }); + }, db); } return row; } @@ -171,7 +179,7 @@ export async function resolveUser(user) { // ── Tier 3: trusted-token-email → Users.email (+ self-heal link write) ── const email = tokenEmail(user); if (email) { - const rows = await SELECT.from(Users).where`lower(email) = ${email}`; + const rows = await run(SELECT.from(Users).where`lower(email) = ${email}`); const candidates = (rows ?? []).filter( (r) => r.email && !NOREPLY_GITHUB_RE.test(r.email), ); @@ -185,7 +193,7 @@ export async function resolveUser(user) { provider: providerFromIssuer(isu.issuer), email, emailVerified: true, - }); + }, db); } return row; } @@ -205,15 +213,18 @@ export async function resolveUser(user) { * fail-closed guard then applies. * * @param {object} user — CAP cds.context.user / req.user (mutated in place). + * @param {object} [db] — optional tx/service runner (e.g. cds.tx(req)) threaded + * through to resolveUser so the pin's lookups join the request transaction + * instead of opening a deadlock-prone ambient one on SQLite (#2552). * @returns {Promise} the resolved Users row, or null. */ -export async function pinResolvedUser(user) { +export async function pinResolvedUser(user, db) { if (!user || !user.id || user.id === 'anonymous') return null; // Idempotent: if already pinned this request, don't re-resolve. if (user.attr?.dbUserId) return { ID: user.attr.dbUserId, sapId: user.authInfo?.token?.userId ?? null }; let row; try { - row = await resolveUser(user); + row = await resolveUser(user, db); } catch (err) { cds.log('resolve-db-user').warn('[pinResolvedUser] resolve failed', { email: tokenEmail(user), isu: issuerSubjectFromUser(user), msg: err?.message ?? err }); diff --git a/srv/admin-service.js b/srv/admin-service.js index d7d6e359d..bac429fa6 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -4077,8 +4077,12 @@ export default class AdminService extends cds.ApplicationService { // ── (#1032) Featured missions carousel admin handlers ── // recomputeFeaturedTopics — SuperAdmin manual trigger to force a fresh // snapshot materialisation without waiting for the nightly job. - this.on('recomputeFeaturedTopics', async () => { - const { count, computedAt } = await cds.tx(async (tx) => recomputeSnapshot(tx)); + this.on('recomputeFeaturedTopics', async (req) => { + // Join the request's own DB transaction (cds.tx(req)) instead of opening + // a new root tx with cds.tx(async tx => ...). On single-connection + // in-memory SQLite a second root tx deadlocks against the identity-pin + // before('*') hook's request-tx read (#2552). + const { count, computedAt } = await recomputeSnapshot(cds.tx(req)); resetFtCache(); return { count, computedAt }; }); @@ -4095,7 +4099,7 @@ export default class AdminService extends cds.ApplicationService { // fire a debounced rebuild so hugo/data/featured_topics.json refreshes. this.after(['CREATE', 'UPDATE', 'DELETE'], 'FeaturedTopics', async (_data, req) => { try { - await cds.tx(async (tx) => recomputeSnapshot(tx)); + await recomputeSnapshot(cds.tx(req)); resetFtCache(); } catch (err) { cds.log('admin-featured').warn('inline recompute failed after write:', err.message); diff --git a/srv/server.js b/srv/server.js index 683df3f86..30cfbb7f9 100644 --- a/srv/server.js +++ b/srv/server.js @@ -2269,8 +2269,12 @@ cds.on('served', async () => { srv.before('*', async (req) => { // Only authenticated requests carry a resolvable identity; anonymous // and tech-user paths no-op inside pinResolvedUser. + // Run the resolver's reads/writes on the REQUEST's own transaction + // (cds.tx(req)) rather than letting the bare SELECT/INSERT globals open a + // fresh ambient autocommit tx — on single-connection in-memory SQLite the + // ambient tx deadlocks against a handler's own cds.tx(...) (#2552). try { - await pinResolvedUser(req.user); + await pinResolvedUser(req.user, cds.tx(req)); } catch (err) { cds.log('identity-pin').warn('[before *] pin failed', { service: srv.name, msg: err?.message ?? err }); From e68926622745bf4158ac90de155e58f2372611cd Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 13:20:40 -0400 Subject: [PATCH 04/46] feat(petoberfest-admin): enlarge photos, uploader email, pending filter, submit alerts (#2597) Admin moderation-queue enhancements requested in #2597: - A: Click a submission thumbnail to open a centered, full-size (1280px) preview dialog (keyboard/touch accessible). Replaces the non-functional "Open in New Tab"; sap.m.Image has no hover event, so press is used. First JS extension in this FE app (ext/PhotoThumb.js, CSP-safe core:require). - B: Uploader was blank when the uploader's token lacked given_name/ family_name. Flatten user.email into the PetSubmissions projection as uploaderEmail and show it as an "Email" column beside "Uploader". - C: One-click "Pending" vs "All" quick-filter tabs with counts (UI.SelectionVariant + manifest quickVariantSelection, annotation-only). - D: Notify DevRel when a new submission lands in the moderation queue via the existing ANS alerting path (mirrors channel-submission-service.js): fire-and-forget, DB-gated (ChatSettings.alertsEnabled), fail-open, with a pure testable buildPetoberfestSubmissionAlert() builder. Tests: builder unit tests + a projection-shape test for uploaderEmail. EDMX compiles; UI5 linter clean on the new files (pre-existing manifest-v2 findings untouched). --- app/admin-annotations.cds | 21 ++++++ .../webapp/ext/PhotoThumb.fragment.xml | 10 ++- .../petoberfest/webapp/ext/PhotoThumb.js | 68 +++++++++++++++++++ app/admin/petoberfest/webapp/manifest.json | 13 ++++ srv/admin-service.cds | 6 ++ srv/lib/petoberfest-upload.js | 50 ++++++++++++++ test/unit/petoberfest-admin.test.js | 13 +++- .../unit/petoberfest-submission-alert.test.js | 52 ++++++++++++++ 8 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 app/admin/petoberfest/webapp/ext/PhotoThumb.js create mode 100644 test/unit/petoberfest-submission-alert.test.js diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 2fb8c45c1..20f1137c7 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -4229,6 +4229,7 @@ annotate AdminService.PetSubmissions with @( UI.LineItem: [ { $Type: 'UI.DataField', Value: petName, Label: 'Pet Name' }, { $Type: 'UI.DataField', Value: uploaderName, Label: 'Uploader' }, + { $Type: 'UI.DataField', Value: uploaderEmail, Label: 'Email' }, { $Type: 'UI.DataField', Value: contestTitle, Label: 'Contest' }, { $Type: 'UI.DataField', Value: moderation, Label: 'Status' }, { $Type: 'UI.DataField', Value: uploadedAt, Label: 'Uploaded' }, @@ -4246,6 +4247,7 @@ annotate AdminService.PetSubmissions with @( UI.FieldGroup#Details: { Data: [ { Value: petName, Label: 'Pet Name' }, { Value: uploaderName, Label: 'Uploader' }, + { Value: uploaderEmail, Label: 'Email' }, { Value: contestTitle, Label: 'Contest' }, { Value: contestSlug, Label: 'Contest Slug' }, { Value: moderation, Label: 'Status' }, @@ -4265,10 +4267,29 @@ annotate AdminService.PetSubmissions with @( Capabilities.UpdateRestrictions: { Updatable: false } ); +// Quick-filter tabs (#2597): one-click "Pending" vs "All" views with counts. +// Wired as single-table quickVariantSelection in the FE manifest's +// controlConfiguration for the LineItem. SelectionVariant#All uses an empty +// SelectOptions so it shows the full queue. +annotate AdminService.PetSubmissions with @( + UI.SelectionVariant #All: { + Text: 'All', + SelectOptions: [] + }, + UI.SelectionVariant #Pending: { + Text: 'Pending', + SelectOptions: [{ + PropertyName: moderation, + Ranges: [{ Sign: #I, Option: #EQ, Low: 'PENDING' }] + }] + } +); + annotate AdminService.PetSubmissions with { ID @Common.Label: 'ID'; petName @Common.Label: 'Pet Name'; uploaderName @Common.Label: 'Uploader'; + uploaderEmail @Common.Label: 'Email'; moderation @Common.Label: 'Status'; sizeBytes @Common.Label: 'Size (bytes)'; uploadedAt @Common.Label: 'Uploaded At'; diff --git a/app/admin/petoberfest/webapp/ext/PhotoThumb.fragment.xml b/app/admin/petoberfest/webapp/ext/PhotoThumb.fragment.xml index 6d78237e8..52beae5cd 100644 --- a/app/admin/petoberfest/webapp/ext/PhotoThumb.fragment.xml +++ b/app/admin/petoberfest/webapp/ext/PhotoThumb.fragment.xml @@ -1,11 +1,17 @@ + xmlns:m="sap.m" + core:require="{ handler: 'sap/tutorials/admin/petoberfest/ext/PhotoThumb' }"> + diff --git a/app/admin/petoberfest/webapp/ext/PhotoThumb.js b/app/admin/petoberfest/webapp/ext/PhotoThumb.js new file mode 100644 index 000000000..041ebc8e5 --- /dev/null +++ b/app/admin/petoberfest/webapp/ext/PhotoThumb.js @@ -0,0 +1,68 @@ +sap.ui.define([ + "sap/m/Dialog", + "sap/m/Image", + "sap/m/Button" +], function (Dialog, Image, Button) { + "use strict"; + + // Issue #2597: moderators asked to "see the pictures larger before + // approving" and for a centered large version — the old "Open in New Tab" + // did nothing. sap.m.Image exposes no hover event, so we enlarge on press + // (click / Enter / Space), which is also keyboard- and touch-accessible. + // The full-size (1280px) image is the same auth-gated express route as the + // thumbnail, just without ?size=thumb. + return { + /** + * Open a centered modal dialog showing the full-size submission photo. + * Bound from PhotoThumb.fragment.xml via core:require. + * @param {sap.ui.base.Event} oEvent press event from the thumbnail Image + */ + onEnlarge: function (oEvent) { + var oImageCtrl = oEvent.getSource(); + var oContext = oImageCtrl.getBindingContext(); + if (!oContext) { + return; + } + var sId = oContext.getProperty("ID"); + var sPetName = oContext.getProperty("petName") || "Submission"; + if (!sId) { + return; + } + + var oDialog = new Dialog({ + title: sPetName, + // Cap the dialog to the viewport so very large photos stay + // centered and fully visible; the image scales within. + contentWidth: "90vw", + contentHeight: "90vh", + horizontalScrolling: true, + verticalScrolling: true, + resizable: true, + draggable: true, + content: [ + new Image({ + // Omit ?size=thumb to fetch the 1280px display render. + src: "/admin/petoberfest/photo/" + encodeURIComponent(sId), + densityAware: false, + alt: "Full-size photo of " + sPetName, + // Fit the width of the dialog content; height follows + // the image aspect ratio. + width: "100%" + }) + ], + endButton: new Button({ + text: "Close", + press: function () { + oDialog.close(); + } + }), + afterClose: function () { + oDialog.destroy(); + } + }); + + oDialog.addStyleClass("sapUiContentPadding"); + oDialog.open(); + } + }; +}); diff --git a/app/admin/petoberfest/webapp/manifest.json b/app/admin/petoberfest/webapp/manifest.json index 1cf121cba..5534ad4ce 100644 --- a/app/admin/petoberfest/webapp/manifest.json +++ b/app/admin/petoberfest/webapp/manifest.json @@ -87,6 +87,19 @@ "tableSettings": { "selectionMode": "ForceMulti" }, + "quickVariantSelection": { + "showCounts": true, + "paths": [ + { + "key": "All", + "annotationPath": "com.sap.vocabularies.UI.v1.SelectionVariant#All" + }, + { + "key": "Pending", + "annotationPath": "com.sap.vocabularies.UI.v1.SelectionVariant#Pending" + } + ] + }, "columns": { "PhotoThumbColumn": { "header": "Photo", diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 5103bb37c..a4b55e911 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -173,6 +173,12 @@ service AdminService { // A Tutorial.Author-only user (without Admin) cannot reach this service. entity PetSubmissions as projection on ims.PetSubmissions { ID, petName, uploaderName, moderation, sizeBytes, uploadedAt, + // uploaderName is NULL when the uploader's token lacked given_name/ + // family_name claims (#2597). Flatten the uploader's email from the + // user association so moderators can always identify/contact the + // submitter. Scalar path expression (like contestSlug below) — no LOB, + // no redirect (Users is not projected into AdminService). + user.email as uploaderEmail, petoberfest.slug as contestSlug, petoberfest.title as contestTitle } actions { @(requires: ['Tutorial.Author', 'Admin']) diff --git a/srv/lib/petoberfest-upload.js b/srv/lib/petoberfest-upload.js index 6c73e5ffd..c3ddf844f 100644 --- a/srv/lib/petoberfest-upload.js +++ b/srv/lib/petoberfest-upload.js @@ -4,12 +4,40 @@ import { resolveOrCreatePetUser } from '../petoberfest-service.js'; import { getNextLegacyId } from './legacy-id.js'; import { stampSubmissionId } from './task-record-submission-id.js'; import { rollUpParentsForCompletion } from './completion-rollup.js'; +import * as alerting from './alerting.js'; + +const LOG = cds.log('petoberfest-upload'); const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,254}$/; /** Decoded-image size cap (mirrors the former multer limit). */ export const MAX_PHOTO_BYTES = 10 * 1024 * 1024; +// Pure alert builder (mirrors buildChannelSubmissionAlert in +// channel-submission-service.js): takes the freshly-inserted submission facts +// and returns the ANS alert envelope to raise, or null when there is nothing to +// announce. No I/O, so subject/body are testable in isolation (#2597). A new +// pet-photo submission awaiting moderation is informational, not urgent → +// NOTICE, which routes to the devrel-deploys channel only, never paging on-call. +export function buildPetoberfestSubmissionAlert(submission = {}) { + const id = submission.id || submission.ID; + if (!id) return null; + const contest = submission.contestTitle || submission.contestSlug || 'a contest'; + const uploader = (submission.uploaderName || '').trim() || submission.uploaderEmail || 'an entrant'; + const petName = (submission.petName || '').trim(); + const bodyLines = [ + `A new Petoberfest photo submission${petName ? ` ("${petName}")` : ''} from ${uploader} for ${contest} is awaiting approval.`, + 'Review it in the admin moderation queue: /admin-ui/#Petoberfest-manage', + ]; + return { + eventType: 'PetoberfestSubmissionPending', + severity: 'NOTICE', + subject: `Petoberfest submission awaiting approval: ${petName || contest}`, + body: bodyLines.join(' '), + }; +} + + /** * Decode a JSON upload payload into a raw image Buffer. * Accepts a bare base64 string or a `data:;base64,<...>` URL. @@ -60,6 +88,28 @@ export async function uploadPetSubmission(db, { slug, user, buffer, mimeType, pe uploaderName, ...processed, }); + // #2597: notify DevRel that a new submission landed in the moderation queue. + // Fire-and-forget beside the persisted row — alerting.raise is itself + // fail-open (DB-gated via ChatSettings.alertsEnabled, 5s-capped, never + // throws), so a degraded ANS path can never break or slow the entrant's + // upload. Mirrors channel-submission-service.js. A per-ID resourceName keeps + // each distinct submission outside the plugin's dedup window. uploaderName is + // often null (token lacks given_name/family_name), so the builder falls back + // to the email for a useful alert body. + const alert = buildPetoberfestSubmissionAlert({ + id, + petName, + uploaderName, + uploaderEmail: user.attr?.email, + contestTitle: contest.title, + contestSlug: contest.slug, + }); + if (alert) { + alerting + .raise({ ...alert, category: 'ALERT', resource: { resourceName: `petoberfest-submission-${id}`, resourceType: 'moderation-queue' } }) + .catch((err) => LOG.warn('petoberfest submission alert raise failed (swallowed):', err?.message ?? err)); + } + // Idempotent award: skip if a non-SUPERSEDED PETOBERFEST record already exists for this user+contest. const existing = await db.run(SELECT.one.from(TaskRecords).where({ user_ID: dbUser.ID, taskLegacyId: contest.legacyId, taskType: 'PETOBERFEST', status: { '!=': 'SUPERSEDED' }, diff --git a/test/unit/petoberfest-admin.test.js b/test/unit/petoberfest-admin.test.js index 5e6237e36..e7c40ddf0 100644 --- a/test/unit/petoberfest-admin.test.js +++ b/test/unit/petoberfest-admin.test.js @@ -12,10 +12,21 @@ beforeAll(async () => { db = await cds.connect.to('db'); const { Petoberfests, PetSubmissions, Users } = cds.entities('com.sap.developers.ims'); await db.run(INSERT.into(Petoberfests).entries({ ID: 'p1', legacyId: 9001, slug: 'petoberfest-2026', title: 'P26', status: 'ACTIVE' })); - await db.run(INSERT.into(Users).entries({ ID: 'u1', sapId: 's1' })); + await db.run(INSERT.into(Users).entries({ ID: 'u1', sapId: 's1', email: 'tom@example.com' })); await db.run(INSERT.into(PetSubmissions).entries({ ID: 's1', petoberfest_ID: 'p1', user_ID: 'u1', petName: 'Rex', moderation: 'PENDING', mimeType: 'image/webp', uploadedAt: '2026-08-01T00:00:00Z' })); }); +test('PetSubmissions projection exposes uploaderEmail flattened from the user association (#2597)', async () => { + // uploaderName is often NULL (token lacked given_name/family_name), so the + // admin UI shows the uploader's email as a fallback. Verify the projection + // actually surfaces it from user.email — a scalar path expression, no LOB. + const srv = await cds.connect.to('AdminService'); + const row = await srv.tx({ user: ADMIN_USER }, (tx) => + tx.run(SELECT.one.from('AdminService.PetSubmissions').columns('ID', 'uploaderEmail').where({ ID: 's1' }))); + expect(row).toBeDefined(); + expect(row.uploaderEmail).toBe('tom@example.com'); +}); + test('approve sets moderation APPROVED', async () => { const srv = await cds.connect.to('AdminService'); await srv.tx({ user: ADMIN_USER }, async (tx) => { diff --git a/test/unit/petoberfest-submission-alert.test.js b/test/unit/petoberfest-submission-alert.test.js new file mode 100644 index 000000000..d055c2fa3 --- /dev/null +++ b/test/unit/petoberfest-submission-alert.test.js @@ -0,0 +1,52 @@ +// test/unit/petoberfest-submission-alert.test.js +// Pure, I/O-free tests for the Petoberfest moderation-queue alert builder (#2597). +// Mirrors the channel-submission builder test: assert the ANS envelope shape +// without touching alerting.raise / the DB. +import { describe, test, expect } from 'vitest'; +import { buildPetoberfestSubmissionAlert } from '../../srv/lib/petoberfest-upload.js'; + +describe('buildPetoberfestSubmissionAlert', () => { + test('returns null when there is no submission id (nothing to announce)', () => { + expect(buildPetoberfestSubmissionAlert({})).toBeNull(); + expect(buildPetoberfestSubmissionAlert({ petName: 'Rex' })).toBeNull(); + }); + + test('builds a NOTICE envelope with pet name, uploader name, contest and a deep link', () => { + const alert = buildPetoberfestSubmissionAlert({ + id: 'abc-123', + petName: 'Rex', + uploaderName: 'Tom Jung', + contestTitle: 'Petoberfest 2026', + }); + expect(alert).toMatchObject({ + eventType: 'PetoberfestSubmissionPending', + severity: 'NOTICE', + }); + expect(alert.subject).toContain('Rex'); + expect(alert.body).toContain('Rex'); + expect(alert.body).toContain('Tom Jung'); + expect(alert.body).toContain('Petoberfest 2026'); + // Deep link into the admin moderation queue (shell inbound Petoberfest-manage). + expect(alert.body).toContain('/admin-ui/#Petoberfest-manage'); + }); + + test('falls back to uploaderEmail when uploaderName is null (common: token lacks given/family name)', () => { + const alert = buildPetoberfestSubmissionAlert({ + id: 'abc-123', + petName: 'Rex', + uploaderName: null, + uploaderEmail: 'tom@example.com', + contestTitle: 'Petoberfest 2026', + }); + expect(alert.body).toContain('tom@example.com'); + expect(alert.body).not.toContain('null'); + }); + + test('falls back to a generic entrant label when both name and email are absent', () => { + const alert = buildPetoberfestSubmissionAlert({ id: 'abc-123', contestSlug: 'petoberfest-2026' }); + expect(alert.body).toContain('an entrant'); + expect(alert.body).not.toContain('undefined'); + // Uses contestSlug when contestTitle is absent. + expect(alert.body).toContain('petoberfest-2026'); + }); +}); From 857216d12c84fb890174f33b047e563b24d85a3e Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 13:38:15 -0400 Subject: [PATCH 05/46] docs(auth): design spec for IAS social login, no IdP chooser (#2506) --- .../2026-10-02-ias-social-login-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-02-ias-social-login-design.md diff --git a/docs/superpowers/specs/2026-10-02-ias-social-login-design.md b/docs/superpowers/specs/2026-10-02-ias-social-login-design.md new file mode 100644 index 000000000..16b45d487 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-ias-social-login-design.md @@ -0,0 +1,127 @@ +# IAS Social Login — additive, no IdP chooser (#2506) + +**Date:** 2026-10-02 +**Issue:** [#2506](https://github.com/sap-tutorials/tutorials-ims/issues/2506) — continue IAS usage: enable social login on the IAS infrastructure without breaking the existing default IdP login. +**Status:** Design — pending user review. +**Depends on:** #2552 identity-linkage layer (`(issuer, subject)` resolver) — **already merged + in PROD** (Release 1.33.0, 2026-10-01). + +## Goal + +Let a developer sign in with a **social provider** (Google etc., surfaced inside IAS) while the existing **SAP ID Service** login keeps working exactly as today, and **neither path shows an IdP selection screen**. Social-authenticated users resolve to the **same `user_uuid`** as their SAP ID login (merge by email), so progress/completions carry across. + +## What is already in place (verified 2026-10-02) + +This feature is mostly *configuration that already exists*; the code change is small. + +1. **Subaccount trusts two OIDC IdPs** (live `btp list security/trust`, Tutorial System subaccount `3c6fa3f1-…`): + + | IdP | originKey | protocol | status | + |---|---|---|---| + | SAP ID Service (default) | `sap.default` | OpenID Connect | active | + | IAS tenant `atxgsg7zi` (business users) | `sap.custom` | OpenID Connect | active | + +2. **Social providers are enabled inside the IAS tenant** and verified visible on the IAS login page (user-confirmed 2026-10-02). Enabling/adding social providers is an IAS admin-console action — no app change. + +3. **XSUAA federation re-issues its own token.** The main app binds only `tutorials-xsuaa`. When a user picks `sap.custom`, XSUAA federates to IAS, then re-issues an **XSUAA** token to the app. The main CAP `srv` therefore needs **no** `auth:{kind:ias}` change — it already accepts the token for both IdPs. User-confirmed: logging in via *either* IdP reached real authenticated app pages. + +4. **Identity merge is already deployed.** `packages/core/resolve-db-user.js` (#2552) resolves `(iss, sub)` across providers (Tier 1), self-heals by canonical sapId (Tier 2), and matches trusted token-email → `Users.email` (Tier 3), merging SAP-ID and IAS/social logins onto one `Users` row. `providerFromIssuer` already maps `*.accounts.ondemand.com → 'ias'`. **No resolver change needed.** + +5. **approuter supports IdP pinning.** Installed `@sap/approuter` **v16.9.0** honors the `sap_idp=` query param (translated to XSUAA `login_hint={"origin":""}`), **gated by `dynamicIdentityProvider`** — currently **off**. + +## The gap (what this build actually does) + +Two things, both small: + +1. **Turn on IdP pinning** — enable `dynamicIdentityProvider` so `sap_idp` is honored. Without it, `sap_idp` is silently ignored (`path-rewriter.js:136`). +2. **A two-button sign-in landing page** — app-controlled HTML with two pinned entries so users never see XSUAA's raw chooser: + - **[Sign in with SAP]** → `…?sap_idp=sap.default` → SAP ID Service (today's path). + - **[Sign in with SAP Universal ID]** → `…?sap_idp=sap.custom` → IAS page (social + IAS-local). + + The button label is **not** "Sign in with Google" — our page names the SAP Universal ID / IAS entry; the social provider buttons live *inside* IAS. This keeps the page provider-agnostic (add more social IdPs in IAS with no page change). + +## Section 1 — Architecture + +``` + ┌───────────────────────────────────────┐ + GET /signin │ App landing page (approuter static) │ + ──────────────▶│ [ Sign in with SAP ] │ + │ [ Sign in with SAP Universal ID ] │ + └──────┬──────────────────────┬──────────┘ + sap_idp=sap.default sap_idp=sap.custom + ▼ ▼ + approuter /login (xsuaa, dynamicIdentityProvider:true) + │ login_hint={"origin":"sap.default"} │ {"origin":"sap.custom"} + ▼ ▼ + XSUAA ───────────────► federates to IdP + │ SAP ID Service │ IAS atxgsg7zi (social + local) + └──────────┬────────────┘ + XSUAA re-issues its OWN token (both branches) + ▼ + main approuter + main srv (unchanged) + ▼ + (iss,sub) resolver merges by email → one user_uuid +``` + +- Main approuter login binding (`tutorials-xsuaa`) **unchanged** — no second approuter, no custom OIDC code. +- Main srv auth **unchanged** — XSUAA token for both branches. +- No new service bindings. No MTA resource changes. + +## Section 2 — The no-chooser mechanism + +- `sap_idp=` on an xsuaa-protected URL → approuter emits `login_hint={"origin":""}` on the XSUAA authorize request → XSUAA jumps straight to that IdP, no selection screen (`oauth2.js:31-42`, README v16.9.0 §Dynamic Identity Provider). +- **Gate:** `dynamicIdentityProvider` must be true, set either: + - globally via approuter env `DYNAMIC_IDENTITY_PROVIDER: true` (standalone approuter → applies to all routes), **or** + - per-route `"dynamicIdentityProvider": true` on the sign-in route(s) in `xs-app.json`. + + **Decision (recommended): per-route**, scoped to the sign-in entry route(s), not global — smallest blast radius; leaves all other routes' login behavior exactly as today. (Open for review — see Open Questions.) +- **Session caching:** once XSUAA issues the session cookie, the landing page is not seen again until logout/expiry — the "unobtrusive but powerful, seen once" UX. +- **Caveat — IdP switch needs logout first** (approuter README:932): a user already logged in via one IdP must hit `/logout` before the other `sap_idp` takes effect. The landing page / account menu must route IdP-switch through logout. This is the one real UX wrinkle. + +## Section 3 — Identity continuity (already solved, just verified) + +No code here — this section is a **verification obligation**, not a change. + +- Social/IAS login via `sap.custom` → XSUAA token with IAS-derived `(iss, sub)`. +- First hit: resolver Tier-3 matches the trusted token email to the existing `Users.email` row (the user's SAP ID account), writes a `UserIdentities` link, returns that row → **same `user_uuid`**. +- Subsequent hits: Tier-1 `(iss, sub)` → O(1), no email dependency. +- Grounding risk (from #2552): `Users.email` is NULL on ~97.6% of PROD rows and backfills lazily on login. A social user whose SAP-ID row has **no email yet** will Tier-3-miss and get a **separate** `Users` row (separate `user_uuid`) until a later explicit account-merge. This is the known, accepted #2552 boundary — call it out in testing, do not try to re-solve it here. + +## Section 4 — Scope + +**In scope (code):** +1. `approuter/static/` sign-in landing page (two pinned buttons) + a route for it in `approuter/xs-app.json`. +2. Enable `dynamicIdentityProvider` (per-route) on the sign-in / login route(s) in `approuter/xs-app.json`. +3. IdP-switch-via-logout wiring on the landing page / account menu (handle README:932 caveat). +4. Optional: redirect unauthenticated entry to `/signin` instead of the default XSUAA chooser (so the chooser is never reachable by normal navigation). + +**In scope (config / console, no code):** +5. Confirm IAS social providers enabled (done) + branding of the IAS login page is a separate follow-up (#2506 notes branding is deferred). + +**Out of scope / unchanged:** +- Main srv auth config, identity resolver (`resolve-db-user.js`), XSUAA bindings, second approuter, MTA resources — all untouched. +- Cross-provider explicit account-merge UX (same human, SAP then social, when email was absent) — deferred per #2552. +- IAS login-page branding/custom domain — separate follow-up. + +## Section 5 — Testing + +1. **Both buttons, no chooser:** `/signin` → [SAP] lands on SAP ID Service directly; [SAP Universal ID] lands on IAS page directly; XSUAA selection screen never shown. +2. **Default path regression:** existing deep links / normal navigation for already-authenticated users behave exactly as today; a logged-in session is not forced back through `/signin`. +3. **Identity continuity (happy path):** a user with a populated `Users.email` logs in via social → resolves to the **same `user_uuid`** as their prior SAP ID login; progress visible. +4. **Identity boundary (known #2552 limit):** a user whose SAP-ID row has NULL email logs in via social → gets a separate row; documented, not a regression. +5. **IdP switch:** logged in as SAP, choose Universal ID → confirm logout-first flow works and does not 400/loop. +6. **Role-collection authorization:** an admin/author identity asserted via IAS still authorizes role-gated pages (role collections resolve by identity). +7. **DEV first**, then PROD — same trust config exists on the shared subaccount. + +## Verification before calling done + +- `dynamicIdentityProvider` actually honored on DEV (not silently ignored): confirm `sap_idp` produces a direct IdP jump, inspect the authorize URL carries `login_hint`. +- Admin-UI changes rule (CLAUDE.md): the landing page ships via the approuter static copy at `mbt build` — a FULL deploy, no `--skip-build`, no `-m` scoping; Step 3.5 bundle check applies. +- `cf target` before any push; deploy from fresh `origin/DEV`, never a feature branch. + +## Open questions for review + +1. **Gate scope:** per-route `dynamicIdentityProvider` on the sign-in route (recommended, minimal) vs. global `DYNAMIC_IDENTITY_PROVIDER` env (simpler, broader). Any route accepting `sap_idp` lets a crafted URL steer the IdP — per-route limits that surface. +2. **Entry point:** add a NEW `/signin` page, or convert the existing `/login` → `/login-redirect.html` route into the two-button page? (Existing route at `xs-app.json:116-121`.) +3. **Label:** "Sign in with SAP Universal ID" — confirm wording for the IAS button. +4. **Default unauthenticated behavior:** force all unauthenticated entry to `/signin` (never reach XSUAA's chooser), or only use `/signin` as an explicit opt-in link? Forcing it is the true "no chooser ever" guarantee but touches the default path more. +5. **Tenant drift:** a recalled note mentions a later swap to IAS tenant `alzmza7li`; live trust shows `atxgsg7zi` as the active `sap.custom`. Spec uses the live value. Confirm no pending re-trust. From 62f1813c06799359edfcd1781d8b69d105493440 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 13:43:44 -0400 Subject: [PATCH 06/46] docs(auth): add styling recipe + resolve decisions in IAS social-login spec (#2506) --- .../2026-10-02-ias-social-login-design.md | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-10-02-ias-social-login-design.md b/docs/superpowers/specs/2026-10-02-ias-social-login-design.md index 16b45d487..77dde2ba9 100644 --- a/docs/superpowers/specs/2026-10-02-ias-social-login-design.md +++ b/docs/superpowers/specs/2026-10-02-ias-social-login-design.md @@ -86,6 +86,20 @@ No code here — this section is a **verification obligation**, not a change. - Subsequent hits: Tier-1 `(iss, sub)` → O(1), no email dependency. - Grounding risk (from #2552): `Users.email` is NULL on ~97.6% of PROD rows and backfills lazily on login. A social user whose SAP-ID row has **no email yet** will Tier-3-miss and get a **separate** `Users` row (separate `user_uuid`) until a later explicit account-merge. This is the known, accepted #2552 boundary — call it out in testing, do not try to re-solve it here. +## Section 3.5 — Styling (user requirement) + +The sign-in landing page must **match the site theme** — light/dark mode and our styles — the same way the **error pages** do. It should evoke the SAP Universal ID experience but rendered in our skin (not a raw SAP/IAS screen; that screen appears only *after* the user picks the Universal ID button and is handed to IAS). + +**Author it as a Hugo layout, NOT a hand-written approuter static file.** The error pages (403/404/500…) are Hugo-generated and copied into `approuter/static/` at build (`mta.yaml:98` `cp -r hugo/public/. approuter/static/`). A Hugo-sourced page inherits theming, build, and deploy automatically; a hand-edited `approuter/static/*.html` would be overwritten by the build (and is exactly the "ships dead / not deployed" trap in CLAUDE.md). + +Recipe (verified 2026-10-02): +- **Clone** `hugo/layouts/403.html` — the closest auth-context analog (uses `fd-button`, `/logout`, ``). It's just a `{{ define "main" }}…{{ end }}` block; ``, shellbar, footer, CSS links, and the theme script all come from `hugo/layouts/_default/baseof.html` + `partials/head.html`. +- **Site CSS:** inherited via the shared head `` chain (`fundamental-styles-icon`, `sap-theme-vars`, `sap-horizon-dark`, `chroma-light/dark`, `sap-fundamental`, `ui5-overrides`). Page-specific ` +{{- /* ui5Split: illustration assets for , mirrors 403.html (#1777 Task 7) */}} +{{ if and (not site.Params.previewMode) site.Params.ui5Split }}{{ end }} +{{ end }} +``` + +Note: `tnt/Login` is the login illustration name; if the project's ui5-illustrations bundle doesn't register it, fall back to `tnt/Unlock` or `tnt/Success`. Verify the bundle in Step 2. + +- [ ] **Step 2: Verify the illustration name is available** + +Run: `grep -rn "tnt/Login\|tnt/Unlock\|tnt/Success\|tnt/Lock" hugo/ --include=*.html --include=*.js | head` +Expected: at least one existing reference to a `tnt/*` illustration (confirms the bundle pattern). If `tnt/Login` isn't referenced anywhere and the ui5-illustrations loader uses an explicit allowlist, switch the `name=` to one that is already used (e.g. `tnt/Lock` as in 403.html). Edit the layout accordingly. + +- [ ] **Step 3: Commit** + +```bash +git add hugo/layouts/signin.html +git commit -m "feat(auth): themed two-button sign-in Hugo layout (#2506)" +``` + +--- + +### Task 3: Add the content stub so Hugo renders `/signin/` + +**Files:** +- Create: `hugo/content/signin.md` +- Reference: an existing standalone content page's front matter (e.g. look for a `hugo/content/*.md` that sets a custom `layout` and excludes nav) + +**Interfaces:** +- Consumes: Task 2's `signin.html` layout. +- Produces: a page served at `/signin/` using the `signin` layout, excluded from nav/sitemap/search. Task 4 routes the approuter to it; Task 5 fetches it. + +- [ ] **Step 1: Find the project's convention for a layout-pinned, nav-excluded page** + +Run: `grep -rln "^layout:" hugo/content | head; echo '---'; grep -rln "sitemap:\|_build:\|robots" hugo/content | head` +Expected: shows how existing pages pin a layout and opt out of listing. Mirror whichever convention exists (front-matter `layout:`, `_build:` exclusions, `sitemap: { disable: true }`, or a `headless`/`robots` param). If no example exists, use the Hugo-standard `_build` block below. + +- [ ] **Step 2: Write the content stub** + +Create `hugo/content/signin.md` (adjust keys to match the convention found in Step 1): +```markdown +--- +title: "Sign in" +layout: signin +_build: + list: never +sitemap: + disable: true +robots: "noindex, nofollow" +--- +``` + +- [ ] **Step 3: Build Hugo and confirm the page renders** + +Run (from repo root; fetch cache must exist — if not, `npm run fetch-tutorials` first): +`npm run build:hugo && test -f hugo/public/signin/index.html && echo RENDERED` +Expected: prints `RENDERED`. (Wrap in `scripts/quiet-run.sh` if the Hugo output is noisy.) + +- [ ] **Step 4: Confirm both pinned hrefs are present in the built HTML** + +Run: `grep -o 'sap_idp=sap\.[a-z]*' hugo/public/signin/index.html | sort -u` +Expected: two lines — `sap_idp=sap.custom` and `sap_idp=sap.default`. + +- [ ] **Step 5: Commit** + +```bash +git add hugo/content/signin.md +git commit -m "feat(auth): render /signin page from signin layout (#2506)" +``` + +--- + +### Task 4: Serve `/signin` from the approuter (public route) + +**Files:** +- Modify: `approuter/xs-app.json` (routes array — add BEFORE the catch-all / static fallthrough so it is matched) + +**Interfaces:** +- Consumes: Task 3's built `approuter/static/signin/index.html` (present after the `mta.yaml:98` copy). +- Produces: `GET /signin` serves the themed page unauthenticated (so a logged-out user can reach it without triggering a chooser). The two buttons then drive the authenticated `/login?sap_idp=...` flow. + +- [ ] **Step 1: Add the public `/signin` route** + +In `approuter/xs-app.json`, add this route to the `routes` array (place it near the other static/public page routes, before any broad catch-all): +```json +{ + "source": "^/signin/?(\\?.*)?$", + "authenticationType": "none", + "target": "/signin/index.html", + "localDir": "static" +} +``` + +- [ ] **Step 2: Validate JSON + route ordering** + +Run: `node -e "const c=JSON.parse(require('fs').readFileSync('approuter/xs-app.json','utf8')); const i=c.routes.findIndex(r=>r.source.includes('signin')); console.log('signin route index',i,'of',c.routes.length); process.exit(i<0?1:0)"` +Expected: prints a non-negative index. Confirm by eye it precedes any `"^/(.*)$"`-style catch-all. + +- [ ] **Step 3: Commit** + +```bash +git add approuter/xs-app.json +git commit -m "feat(auth): serve public /signin page from approuter (#2506)" +``` + +--- + +### Task 5: Post-deploy e2e smoke for the sign-in page + +**Files:** +- Create: `test/e2e/signin.spec.ts` +- Reference: `test/e2e/README.md` and an existing `test/e2e/*.spec.ts` for the self-skip + base-URL pattern + +**Interfaces:** +- Consumes: the deployed `/signin` page (needs `SMOKE_BASE_URL`). +- Produces: a self-skipping Playwright spec asserting both buttons + hrefs render. + +- [ ] **Step 1: Read the existing e2e self-skip pattern** + +Run: `grep -rn "SMOKE_BASE_URL\|test.skip\|baseURL" test/e2e | head` +Expected: shows how specs read `SMOKE_BASE_URL` and self-skip when absent. Mirror it exactly in Step 2. + +- [ ] **Step 2: Write the spec (mirroring the pattern from Step 1)** + +Create `test/e2e/signin.spec.ts` (adapt imports/skip to match Step 1's convention): +```ts +import { test, expect } from '@playwright/test'; + +const BASE = process.env.SMOKE_BASE_URL; + +test.describe('sign-in page (#2506)', () => { + test.skip(!BASE, 'SMOKE_BASE_URL not set — post-deploy only'); + + test('renders both IdP-pinned sign-in buttons', async ({ page }) => { + await page.goto(`${BASE}/signin`); + const sapBtn = page.getByRole('link', { name: 'Sign in with SAP', exact: true }); + const uidBtn = page.getByRole('link', { name: 'Sign in with SAP Universal ID' }); + await expect(sapBtn).toBeVisible(); + await expect(uidBtn).toBeVisible(); + await expect(sapBtn).toHaveAttribute('href', '/login?sap_idp=sap.default'); + await expect(uidBtn).toHaveAttribute('href', '/login?sap_idp=sap.custom'); + }); +}); +``` + +- [ ] **Step 3: Verify it self-skips locally (no SMOKE_BASE_URL)** + +Run: `npx playwright test test/e2e/signin.spec.ts` (or the repo's `npm run test:e2e` script) +Expected: the test is SKIPPED (not failed) because `SMOKE_BASE_URL` is unset. + +- [ ] **Step 4: Commit** + +```bash +git add test/e2e/signin.spec.ts +git commit -m "test(auth): post-deploy e2e smoke for /signin page (#2506)" +``` + +--- + +### Task 6: Document the sign-in flow + +**Files:** +- Modify: `docs/developers/architecture/authentication.md` + +**Interfaces:** +- Consumes: the final behavior from Tasks 1–4. +- Produces: documentation only. + +- [ ] **Step 1: Add a "Two-button sign-in + IdP pinning" section** + +Append to `docs/developers/architecture/authentication.md` a section covering: +- `/signin` is a public themed page; buttons link to `/login?sap_idp=sap.default` (SAP ID Service) and `/login?sap_idp=sap.custom` (IAS `atxgsg7zi`, social providers). +- `sap_idp` → approuter `login_hint={"origin":...}` → XSUAA jumps straight to that IdP; `dynamicIdentityProvider:true` on `/login` is the gate. +- Both IdPs federate through XSUAA, which re-issues its own token; the `(iss,sub)` resolver (#2552) merges to one `user_uuid` by email. +- Caveat: switching IdPs requires `/logout` first. +- Known boundary: a social login whose SAP-ID row has NULL email gets a separate row until explicit account-merge (#2552 limit). + +- [ ] **Step 2: Commit** + +```bash +git add docs/developers/architecture/authentication.md +git commit -m "docs(auth): document two-button sign-in and sap_idp pinning (#2506)" +``` + +--- + +### Task 7: DEV deploy + live verification + +**Files:** none (deploy + manual/automated verification). + +**Interfaces:** +- Consumes: all prior tasks on the branch, merged to DEV. +- Produces: a verified working `/signin` on DEV. + +- [ ] **Step 1: Confirm clean tree + correct branch, open PR to DEV** + +```bash +git status --porcelain # expect empty +gh pr create --base DEV --fill +``` + +- [ ] **Step 2: After merge, deploy DEV from fresh origin/DEV (full build)** + +Follow CLAUDE.md canonical deploy (set `CAP_BASE_URL` to the deployed DEV srv, `npm run build:all` before `mbt build`, FULL deploy — no `--skip-build`, no `-m`). Wrap noisy commands in `scripts/quiet-run.sh`. Confirm `cf target` is DEV first. + +- [ ] **Step 3: Verify the chooser is suppressed for each button** + +In a logged-out browser: +- Visit `/signin` → confirm themed page, both buttons, light/dark follows the site toggle. +- Click **Sign in with SAP** → lands DIRECTLY on SAP ID Service (no "default vs custom" screen). +- Log out. Click **Sign in with SAP Universal ID** → lands DIRECTLY on the IAS page showing social providers (no chooser). +- Confirm the authorize URL carries `login_hint={"origin":"sap.default"}` resp. `"sap.custom"` (DevTools network, the redirect to XSUAA). If the chooser still appears, `dynamicIdentityProvider` is not being honored — recheck Task 1. + +- [ ] **Step 4: Verify identity continuity** + +Log in via SAP ID (note your progress), log out, log in via SAP Universal ID with the same email → confirm the SAME account/progress (same `user_uuid`). If a separate account appears, confirm whether the SAP-ID row had a populated email (known #2552 boundary) before treating it as a defect. + +- [ ] **Step 5: Verify default-path regression** + +Confirm normal navigation and already-authenticated sessions are unaffected (existing deep links still work; no one is bounced to `/signin` unexpectedly). + +--- + +## Self-Review + +**Spec coverage:** +- Enable `dynamicIdentityProvider` → Task 1. ✓ +- Two-button themed page (Hugo-sourced, cloned from 403, light/dark, logo) → Tasks 2–3. ✓ +- `sap_idp` pinning per button → Tasks 2 (hrefs) + 1 (gate). ✓ +- Public `/signin` served by approuter → Task 4. ✓ +- Identity continuity (no change, verify only) → Task 7 Step 4. ✓ +- IdP-switch-via-logout caveat → documented Task 6; buttons rely on `/login` which handles fresh login. (Signed-in account-menu "switch IdP via logout" wiring is a UI nicety — see Deferred.) +- Styling recipe → Tasks 2–3. ✓ +- Testing (both buttons, regression, role auth) → Tasks 5 + 7. ✓ + +**Placeholder scan:** No TBD/TODO; every code step has concrete content; illustration-name and front-matter-convention uncertainties are handled with explicit verify-and-adjust steps rather than left vague. + +**Type consistency:** Origin keys `sap.default`/`sap.custom`, hrefs `/login?sap_idp=...`, and button labels are identical across Tasks 1, 2, 5, 6. ✓ + +## Deferred (not this plan) + +- **Force ALL unauthenticated entry to `/signin`** (global redirect so the raw XSUAA chooser is unreachable by any path). The current plan makes `/signin` the sign-in CTA; a global redirect rewrite touches the default path broadly and is a separate hardening step once the page is proven on DEV. +- **Signed-in "switch IdP" account-menu item** routing through `/logout?returnTo=/signin` — UI nicety, additive later. +- **IAS login-page branding / custom domain** — separate follow-up per #2506. +- **Cross-provider explicit account-merge UX** (same human, SAP then social, when email was absent) — #2552 deferred item. From 88108ac8f754fc0962ccb01d4220f5ded2645e7f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 13:58:19 -0400 Subject: [PATCH 10/46] feat(2595): blockquote render hook with semantic alert classes --- .../_default/_markup/render-blockquote.html | 11 ++++++++++ test/parsers/render-blockquote-hook.test.ts | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 hugo/layouts/_default/_markup/render-blockquote.html create mode 100644 test/parsers/render-blockquote-hook.test.ts diff --git a/hugo/layouts/_default/_markup/render-blockquote.html b/hugo/layouts/_default/_markup/render-blockquote.html new file mode 100644 index 000000000..72e367786 --- /dev/null +++ b/hugo/layouts/_default/_markup/render-blockquote.html @@ -0,0 +1,11 @@ +{{- /* Blockquote render hook (#2595). + Plain `>` (AlertType == "") renders byte-identical to Hugo's default so the + global blue blockquote style and single.html's scoped style both keep + matching. GitHub-style alerts (`> [!TYPE]`) get a semantic class; CSS in + sap-fundamental.src.css maps each to a SAP MessageStrip color (color-only). + Unknown designators resolve to AlertType "" and take the plain path. */ -}} +{{- if eq .AlertType "" -}} +
{{ .Text }}
+{{- else -}} +
{{ .Text }}
+{{- end -}} diff --git a/test/parsers/render-blockquote-hook.test.ts b/test/parsers/render-blockquote-hook.test.ts new file mode 100644 index 000000000..8790d6465 --- /dev/null +++ b/test/parsers/render-blockquote-hook.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' + +const p = 'hugo/layouts/_default/_markup/render-blockquote.html' + +describe('render-blockquote hook', () => { + it('exists', () => { + expect(existsSync(p)).toBe(true) + }) + + it('has a plain-`>` passthrough branch (no class) preserving today\'s default', () => { + const t = readFileSync(p, 'utf8') + expect(t).toContain('.AlertType') + expect(t).toMatch(/eq \.AlertType ""/) + expect(t).toMatch(/
\s*\{\{[-\s]*\.Text/) + }) + + it('emits a semantic alert class for alert blockquotes', () => { + const t = readFileSync(p, 'utf8') + expect(t).toContain('class="alert alert-{{ .AlertType }}"') + }) +}) From 83a0c257b197365cba36d50d837cce65a98f039d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 14:05:54 -0400 Subject: [PATCH 11/46] feat(2595): color-only CSS for blockquote alert variants --- hugo/assets/css/sap-fundamental.css | 23 ++++++++++++++ hugo/assets/css/sap-fundamental.src.css | 23 ++++++++++++++ test/parsers/blockquote-alert-css.test.ts | 38 +++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 test/parsers/blockquote-alert-css.test.ts diff --git a/hugo/assets/css/sap-fundamental.css b/hugo/assets/css/sap-fundamental.css index 1373bc1d0..809bfcfb6 100644 --- a/hugo/assets/css/sap-fundamental.css +++ b/hugo/assets/css/sap-fundamental.css @@ -2371,6 +2371,29 @@ blockquote { margin: 1rem 0; } +/* Semantic alert callouts (#2595) — author opt-in via `> [!TYPE]`. + Base `blockquote` (blue Information) remains the default for plain `>`. + Color-only: override border + fill, nothing else. Horizon-light hex are + fallbacks because several --sap*Background/BorderColor tokens are not + declared in this project's theme-vars. */ +blockquote.alert-note, +blockquote.alert-important { + border-left-color: var(--sapInformationBorderColor, #0070f2); + background-color: var(--sapInformationBackground, #e1f4ff); +} +blockquote.alert-tip { + border-left-color: var(--sapSuccessBorderColor, #30914c); + background-color: var(--sapSuccessBackground, #f5fae5); +} +blockquote.alert-warning { + border-left-color: var(--sapWarningBorderColor, #dd6100); + background-color: var(--sapWarningBackground, #fff8d6); +} +blockquote.alert-caution { + border-left-color: var(--sapErrorBorderColor, #e90b0b); + background-color: var(--sapErrorBackground, #ffeaf4); +} + /* Tutorial meta bar (tags + time) */ .tutorial-meta { display: flex; diff --git a/hugo/assets/css/sap-fundamental.src.css b/hugo/assets/css/sap-fundamental.src.css index 0d5a75e7d..53103bad9 100644 --- a/hugo/assets/css/sap-fundamental.src.css +++ b/hugo/assets/css/sap-fundamental.src.css @@ -879,6 +879,29 @@ blockquote { margin: 1rem 0; } +/* Semantic alert callouts (#2595) — author opt-in via `> [!TYPE]`. + Base `blockquote` (blue Information) remains the default for plain `>`. + Color-only: override border + fill, nothing else. Horizon-light hex are + fallbacks because several --sap*Background/BorderColor tokens are not + declared in this project's theme-vars. */ +blockquote.alert-note, +blockquote.alert-important { + border-left-color: var(--sapInformationBorderColor, #0070f2); + background-color: var(--sapInformationBackground, #e1f4ff); +} +blockquote.alert-tip { + border-left-color: var(--sapSuccessBorderColor, #30914c); + background-color: var(--sapSuccessBackground, #f5fae5); +} +blockquote.alert-warning { + border-left-color: var(--sapWarningBorderColor, #dd6100); + background-color: var(--sapWarningBackground, #fff8d6); +} +blockquote.alert-caution { + border-left-color: var(--sapErrorBorderColor, #e90b0b); + background-color: var(--sapErrorBackground, #ffeaf4); +} + /* Tutorial meta bar (tags + time) */ .tutorial-meta { display: flex; diff --git a/test/parsers/blockquote-alert-css.test.ts b/test/parsers/blockquote-alert-css.test.ts new file mode 100644 index 000000000..bd642f501 --- /dev/null +++ b/test/parsers/blockquote-alert-css.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' + +const src = 'hugo/assets/css/sap-fundamental.src.css' +const built = 'hugo/assets/css/sap-fundamental.css' + +describe('blockquote alert variant CSS', () => { + it('source defines all four variant selectors', () => { + const t = readFileSync(src, 'utf8') + expect(t).toContain('blockquote.alert-note') + expect(t).toContain('blockquote.alert-important') + expect(t).toContain('blockquote.alert-tip') + expect(t).toContain('blockquote.alert-warning') + expect(t).toContain('blockquote.alert-caution') + }) + + it('tip maps to Horizon success green, caution to error red, warning to amber', () => { + const t = readFileSync(src, 'utf8') + expect(t).toContain('#30914c') // success border + expect(t).toContain('#f5fae5') // success bg + expect(t).toContain('#dd6100') // warning border + expect(t).toContain('#fff8d6') // warning bg + expect(t).toContain('#e90b0b') // error border + expect(t).toContain('#ffeaf4') // error bg + }) + + it('built css was regenerated with the variant selectors', () => { + const t = readFileSync(built, 'utf8') + expect(t).toContain('.alert-tip') + expect(t).toContain('.alert-warning') + expect(t).toContain('.alert-caution') + }) + + it('does not alter the base blockquote default (blue Information still present)', () => { + const t = readFileSync(src, 'utf8') + expect(t).toContain('--sapInformationBackground') + }) +}) From a71c777b35ea5f0cf4c0dbed6affd7352a673039 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 14:06:13 -0400 Subject: [PATCH 12/46] feat(auth): honor sap_idp on /login route via dynamicIdentityProvider (#2506) --- approuter/xs-app.json | 1 + 1 file changed, 1 insertion(+) diff --git a/approuter/xs-app.json b/approuter/xs-app.json index 81a048fd9..2f3bc4308 100644 --- a/approuter/xs-app.json +++ b/approuter/xs-app.json @@ -116,6 +116,7 @@ { "source": "^/login(\\?.*)?$", "authenticationType": "xsuaa", + "dynamicIdentityProvider": true, "target": "/login-redirect.html", "localDir": "static" }, From 28ead936c95596ed09298f55e38d740c9232fe22 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 14:08:00 -0400 Subject: [PATCH 13/46] docs: sync architecture docs to the split MTA (MCP now a separate MTA) (#2589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP-auth microservice was split out of the main `tutorials-ims` MTA into its own `tutorials-mcp` MTA (`mta-mcp.yaml`, module `tutorials-srv-mcp`) across #2569/#2579/#2581 and the IAS OAuth cutover, but the docs still described a single MTA with the whole MCP surface living in `tutorials-srv`. Corrects the topology across 11 docs: - Only the authenticated `/mcp-auth/*` surface moved to `tutorials-srv-mcp` (separate MTA, hybrid IAS-PKCE + XSUAA auth, proxied via the approuter's `srv-mcp-api` destination). Anonymous `/mcp/*`, PAT `/mcp-pat/*`, and admin `/mcp-admin/*` stay on the main `tutorials-srv`. - `MCP_AUTH_ENABLED` now targets `tutorials-srv-mcp`; `MCP_PAT_MINT_ENABLED` / `KG_STEP_SLICER_ENABLED` stay on `tutorials-srv`. - The MCP MTA adopts the main MTA's services as existing-service; it deploys by hand (`mbt build -e mta-mcp.yaml` + `cf deploy ... -e deploy/mcp-.mtaext`) and `npm run deploy` does NOT cover it — documented honestly. - Fixed a pre-existing contradiction: `.well-known` is runtime approuter middleware, not static files. - Refreshed stale `tutorials-ims_1.0.0.mtar` literals to the `*.mtar` glob in live runbooks (dated rename-cutover commands left historically accurate). Docs-only; verified with a clean `npm run docs:build` (no broken links). --- CLAUDE.md | 9 +++ README.md | 9 ++- docs/developers/architecture/mcp-server.md | 80 ++++++++++++------- docs/developers/getting-started.md | 7 +- docs/developers/operations/deployment.md | 45 +++++++---- docs/developers/operations/mcp-server.md | 62 ++++++++++---- docs/developers/operations/mta-deployment.md | 74 ++++++++++++++--- .../developers/operations/production-ready.md | 2 +- .../operations/qa-channel-bootstrap.md | 2 +- docs/end-users/mcp-quickstart.md | 2 +- docs/pilot-status.md | 2 +- 11 files changed, 216 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb3243fe6..c12c46a06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,15 @@ Subsystem one-liners: `mbt build` only `cp`'s `hugo/public/` into the approuter — it does **not** run Hugo or `fetch-tutorials`. Local builds MUST run `npm run build:all` before `mbt build`; skipping ships stale approuter (missing NEW badges, license icons, progress UI). Always confirm deploy scope with maintainer (backend-only / +content / +QA). +- **The MCP server is a SEPARATE MTA (`tutorials-mcp`, `mta-mcp.yaml`)** — the command above deploys only the main `tutorials-ims` MTA (`.deploy/mta.yaml`), which serves the anonymous `/mcp/*`, PAT `/mcp-pat/*`, and admin `/mcp-admin/*` surfaces. The authenticated `/mcp-auth/*` surface runs in the `tutorials-srv-mcp` app of the separate `tutorials-mcp` MTA. `npm run deploy` / `scripts/deploy-mta.cjs` do NOT cover it — deploy it by hand from repo root: + + ```bash + mbt build -e mta-mcp.yaml -t mta_archives + cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f # or deploy/mcp-prod.mtaext + ``` + + The MCP MTA adopts the main MTA's services (hana/xsuaa/identity/credstore/destination) as `existing-service`, so deploy the main MTA first on a fresh space. Feature flag `MCP_AUTH_ENABLED` targets `tutorials-srv-mcp`; `MCP_PAT_MINT_ENABLED`/`KG_STEP_SLICER_ENABLED` target `tutorials-srv`. See [mta-deployment.md § MCP MTA](docs/developers/operations/mta-deployment.md#mcp-mta-tutorials-mcp). + - **Local deploy is envsubst-free** — All four secrets (`CONTENT_API_KEY`, `REBUILD_API_KEY`, `APPROUTER_URL`, `GITHUB_DISPATCH_TOKEN`) formerly injected via `envsubst` now live exclusively in the BTP Credential Store (or have been removed entirely, in APPROUTER_URL's case). Run `cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f` directly. Rotation happens through `/admin-ui/#secrets` on the target env's approuter. See [mta-deployment.md](docs/developers/operations/mta-deployment.md) "Local deploy no longer needs envsubst" for the full context. - **PR over direct merge** — Default to `gh pr create` from a feature branch; subagent code review is not a substitute for PR review. Only fast-merge to `main` if explicitly told to skip the PR. diff --git a/README.md b/README.md index f205739e1..c2e1b2199 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,17 @@ tutorials-ims/ │ ├── improvements.md, TODO.md# Feature backlog and gap tracking (largely historic) │ └── superpowers/specs/+plans/ # Feature specs and step-by-step implementation plans ├── .deploy/ # MTA build + deploy artifacts -│ ├── mta.yaml # MTA descriptor (modules: approuter, srv, srv-qa, db, db-qa, destinations) +│ ├── mta.yaml # Main MTA descriptor (modules: approuter, srv, srv-qa, db, db-qa, destinations) │ ├── xs-security.json # XSUAA scopes + role collections (Admin, MobileApp, Tutorial.Author) │ ├── deploy-admin.sh # Standalone admin UI deploy helper (bypasses MTA build) │ └── DEPLOY.md # Deploy procedure documentation +├── mta-mcp.yaml # Separate MCP MTA descriptor (tutorials-mcp → module tutorials-srv-mcp, serves /mcp-auth/*; adopts main-MTA services) ├── deploy/ # MTA extension descriptors (environment overrides) -│ ├── dev.mtaext # Development overrides (instance counts, memory) +│ ├── dev.mtaext # Main-MTA development overrides (instance counts, memory, srv-mcp-url) │ ├── qa.mtaext # QA/staging overrides -│ └── prod.mtaext # Production overrides +│ ├── prod.mtaext # Production overrides +│ ├── mcp-dev.mtaext # MCP-MTA DEV overrides (tutorials-mcp-dev) +│ └── mcp-prod.mtaext # MCP-MTA PROD overrides (tutorials-mcp-prod) ├── .github/workflows/ # CI/CD pipelines (GitHub Actions) │ ├── deploy.yml # Build MTA + deploy to BTP CF + post-deploy smoke tests │ ├── rebuild-content.yml # Re-fetch tutorials + rebuild Hugo + publish HTML to HANA diff --git a/docs/developers/architecture/mcp-server.md b/docs/developers/architecture/mcp-server.md index f0fc3da53..a9b71ffd6 100644 --- a/docs/developers/architecture/mcp-server.md +++ b/docs/developers/architecture/mcp-server.md @@ -2,34 +2,54 @@ How the Model Context Protocol surface is structured across CAP services, approuter routes, and the shared step-HTML slicer. For the design rationale, see the spec at `docs/superpowers/specs/2026-07-08-mcp-server-phase2-design.md`. -## Three-route stack +## Deployment topology — two MTAs + +The MCP surface spans **two separately-deployed MTAs** (see +[operations/mta-deployment.md](../operations/mta-deployment.md)): + +- **`tutorials-ims`** (main MTA, `.deploy/mta.yaml`) — the `tutorials-srv` CAP runtime + + the approuter. Serves the anonymous `/mcp/*`, PAT `/mcp-pat/*`, and admin `/mcp-admin/*` + surfaces, and **owns** the shared services (HANA, XSUAA, destination, credstore, …). +- **`tutorials-mcp`** (MCP MTA, root `mta-mcp.yaml`) — a single `tutorials-srv-mcp` Node.js + app serving only the authenticated `/mcp-auth/*` surface. It **adopts** the main MTA's + services as `existing-service` (it creates none of its own) and authenticates inbound + requests with a hybrid IAS (public PKCE) + XSUAA scheme. + +The approuter (in the main MTA) fronts both: `/mcp-auth/*` is proxied to the external +`tutorials-srv-mcp` app via the `srv-mcp-api` destination (`${srv-mcp-url}`); every other +`/mcp*` route goes to the local `srv-api`. + +## Four-route stack ``` Internet / MCP client | Approuter (xs-app.json) | - ┌────┴──────────────────────────────────────────────────────────┐ - │ Route Auth Description │ - │ /mcp/* none Anonymous read-only tools │ - │ /mcp-auth/* XSUAA OAuth JWT Authenticated tools │ - │ /mcp-pat/* Bearer PAT Headless / CI agents │ - └───────────────────────────────────────────────────────────────┘ - | - CAP runtime (tutorials-srv) - | - ┌────┴──────────────────────────────────────────────────────────┐ - │ @cap-js/mcp adapter │ - │ Registers each @protocol:['odata','mcp'] service at │ - │ /mcp/ (anonymous) and │ - │ /mcp-auth/ / /mcp-pat/ │ - │ (via the approuter prefix routing above) │ - └───────────────────────────────────────────────────────────────┘ + ┌────┴──────────────────────────────────────────────────────────────┐ + │ Route Auth Backend app │ + │ /mcp/* none tutorials-srv (srv-api) │ + │ /mcp-pat/* Bearer PAT (in CAP) tutorials-srv (srv-api) │ + │ /mcp-admin/* XSUAA (at approuter) tutorials-srv (srv-api) │ + │ /mcp-auth/* IAS PKCE / XSUAA tutorials-srv-mcp (srv-mcp- │ + │ (in the MCP app) api → separate MTA) │ + └─────────────────────────────────────────────────────────────────────┘ + | | + CAP runtime (tutorials-srv) MCP app (tutorials-srv-mcp) + | | + ┌────┴───────────────────────────────┐ ┌────────┴──────────────────────┐ + │ @cap-js/mcp adapter │ │ srv-mcp/server.js rewrites │ + │ Registers each @protocol: │ │ /mcp-auth/* → /mcp/* onto its │ + │ ['odata','mcp'] service at │ │ own DeveloperService handlers │ + │ /mcp/ (anonymous); │ │ (the 7 authenticated MCP │ + │ /mcp-pat/ via PAT middleware;│ │ read/write tools). Hybrid │ + │ /mcp-admin/* via the compose layer │ │ IAS+XSUAA inbound auth. │ + └─────────────────────────────────────┘ └────────────────────────────────┘ ``` -**Anonymous route (`/mcp/*`):** No XSUAA round-trip. The approuter's `authenticationType: none` route forwards directly to `srv-api`. Participating services: `SearchService`, `HomepageService`, `KnowledgeGraphService`. +**Anonymous route (`/mcp/*`):** No XSUAA round-trip. The approuter's `authenticationType: none` route forwards directly to `srv-api` (the main `tutorials-srv`). Participating services: `SearchService`, `HomepageService`, `KnowledgeGraphService`. -**Authenticated route (`/mcp-auth/*`):** The approuter enforces a valid XSUAA bearer. The CAP runtime receives `req.user` pre-populated by the XSUAA middleware. Participating services: `DeveloperService`, `HomepageService` (authenticated tools). +**Authenticated route (`/mcp-auth/*`):** Served by the **separate `tutorials-srv-mcp` app** (MCP MTA), proxied through the approuter's `srv-mcp-api` destination with `authenticationType: none` at the approuter — the bearer is validated inside the MCP app, not at the approuter. Inbound auth is **hybrid**: an IAS public-PKCE client (primary, for `mcp-remote` clients) with an XSUAA bearer fallback. `srv-mcp/server.js` rewrites `/mcp-auth/*` → `/mcp/*` onto its own `DeveloperService` handlers (the 7 authenticated read/write tools). `HomepageService` authenticated tools are also reachable here. **PAT route (`/mcp-pat/*`):** The approuter uses `authenticationType: none` (the PAT is not a XSUAA token — the approuter cannot validate it). The CAP middleware `srv/lib/mcp-pat-middleware.js` intercepts every request before CAP's routing, validates the `Authorization: Bearer pat_...` header against the `PersonalAccessTokens` HANA table (SHA-256 comparison, TTL check), and synthesises a `req.user` object with the matched user's SAP ID and a `tokenSource: 'pat'` marker. @@ -47,32 +67,34 @@ Full adapter reference: . ## `req.user` resolution -All authenticated tools ultimately call `resolveDbUser(req.user)` to look up the user's database row. Three paths converge on this function: +All authenticated tools ultimately resolve the caller to a database row. Note the two paths +run in **different processes**: `/mcp-auth/*` resolves inside the separate `tutorials-srv-mcp` +app (MCP MTA), while `/mcp-pat/*` resolves inside the main `tutorials-srv` runtime. ``` -/mcp-auth/* → XSUAA middleware → req.user.id = SAP universal ID (from JWT sub) - req.user.tokenSource = undefined (JWT) - ↓ - resolveDbUser(req.user) +/mcp-auth/* → tutorials-srv-mcp → IAS PKCE (primary) or XSUAA (fallback) bearer + (MCP MTA) ↓ + tiered resolveUser(req.user) + pinResolvedUser (#2552) + req.user.id = SAP universal ID (from the token sub) ↓ DB row from Users WHERE sapId = req.user.id -/mcp-pat/* → mcp-pat-middleware → req.user.id = sapId from PersonalAccessTokens - req.user.tokenSource = 'pat' +/mcp-pat/* → tutorials-srv → mcp-pat-middleware → req.user.id = sapId from PAT + (main MTA) req.user.tokenSource = 'pat' req.user.roles = ['pat-read'] + ['pat-write'] (if scopes include 'write') ↓ - resolveDbUser(req.user) (same function) + resolveDbUser(req.user) ↓ DB row from Users WHERE sapId = req.user.id ``` -If `resolveDbUser` returns null (user not in the DB, stale OAuth clientId, or unmigrated user), the handler emits a WARN log `[mcp-dev] resolveDbUser miss` and rejects with 401. This surfaces stale-token issues without a silent zero-row response — see [[silent-user-resolution-hides-token-bugs]] in MEMORY.md. +If user resolution returns null (user not in the DB, stale OAuth clientId, or unmigrated user), the handler emits a WARN log `[mcp-dev] resolveDbUser miss` and rejects with 401. This surfaces stale-token issues without a silent zero-row response — see [[silent-user-resolution-hides-token-bugs]] in MEMORY.md. ## Shared step-HTML slicer `srv/lib/tutorial-step-slicer.js` is the single implementation for extracting a step's HTML from the HANA content BLOB. Four consumers: -1. `DeveloperService.get_tutorial_step` (authenticated MCP, `/mcp-auth/*`) +1. `DeveloperService.get_tutorial_step` (authenticated MCP, `/mcp-auth/*`) — runs in the separate `tutorials-srv-mcp` app, which consumes the slicer logic via the shared `mcp.bundle.mjs` bundle (`scripts/bundle-shared.cjs`), not a second copy of the source 2. `SearchService.get_tutorial_step` (anonymous MCP, `/mcp/search`) 3. `srv/lib/code-check-step-loader.js` (Joule `checkStepCode`) 4. `srv/lib/chat-context.js` server-side fallback diff --git a/docs/developers/getting-started.md b/docs/developers/getting-started.md index 138c4967b..aebf4806b 100644 --- a/docs/developers/getting-started.md +++ b/docs/developers/getting-started.md @@ -160,9 +160,14 @@ When CI is broken or you need a quick iterative deploy, build and push from `.de cf target -s cd .deploy mbt build -cf deploy mta_archives/tutorials-ims_1.0.0.mtar -e ../deploy/dev.mtaext -f +cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f ``` +> This deploys the **main** `tutorials-ims` MTA only. The authenticated MCP surface +> (`/mcp-auth/*`) ships as a **separate** `tutorials-mcp` MTA that deploys by hand: +> `mbt build -e mta-mcp.yaml -t mta_archives && cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f` +> (from repo root). See [operations/mta-deployment.md § MCP MTA](operations/mta-deployment.md#mcp-mta-tutorials-mcp). + ### Optional env vars ```bash diff --git a/docs/developers/operations/deployment.md b/docs/developers/operations/deployment.md index 308b219ba..c4cbfcca8 100644 --- a/docs/developers/operations/deployment.md +++ b/docs/developers/operations/deployment.md @@ -7,40 +7,57 @@ description: SAP BTP Cloud Foundry deployment — MTA modules, BTP service bindi > Source: extracted from project README, 2026-05-25. -Single MTA deployment to SAP BTP Cloud Foundry: +Two MTAs deploy to SAP BTP Cloud Foundry: the **main** `tutorials-ims` MTA +([.deploy/mta.yaml](../../../.deploy/mta.yaml)) and the **MCP** `tutorials-mcp` MTA +([mta-mcp.yaml](../../../mta-mcp.yaml)). The main MTA owns the service instances; the MCP MTA +adopts them. Main-MTA deploy: ```bash mbt build -cf deploy mta_archives/tutorials-ims_1.0.0.mtar +cf deploy mta_archives/*.mtar ``` +MCP-MTA deploy is separate and manual (`npm run deploy` does not cover it): + +```bash +mbt build -e mta-mcp.yaml -t mta_archives +cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f +``` + +See [operations/mta-deployment.md](mta-deployment.md) for the full runbook. + #### MTA Modules -The deployment in [.deploy/mta.yaml](../../../.deploy/mta.yaml) defines five modules — the prod and QA channels share an AppRouter and XSUAA instance but each gets its own srv app and HDI container. +The main MTA ([.deploy/mta.yaml](../../../.deploy/mta.yaml)) defines five modules — the prod and QA channels share an AppRouter and XSUAA instance but each gets its own srv app and HDI container. The MCP MTA ([mta-mcp.yaml](../../../mta-mcp.yaml)) adds a sixth deploy unit, `tutorials-srv-mcp`. -| Module | Type | Source | Requires | Purpose | -| --- | --- | --- | --- | --- | -| `tutorials-db-deployer` | `hdb` | `gen/db` | `tutorials-hana`, `tutorials-cloud-logging` | Prod HANA schema + indexes (one-shot HDI deploy). Cloud Logging binding forwards deployer stdout for ~30-day forensic retention (#257). | -| `tutorials-db-qa-deployer` | `hdb` | `gen/db-qa` | `tutorials-hana-qa`, `tutorials-cloud-logging` | QA HANA schema (peer of `db/`, namespace `com.sap.developers.ims.qa`). Cloud Logging binding mirrors prod for QA forensic parity. | -| `tutorials-srv` | `nodejs` | `gen/srv` | hana, xsuaa, destination, mail, audit-log, cloud-logging, aicore | CAP backend (9 services + jobs + Socket.IO + content store + RAG) | -| `tutorials-srv-qa` | `nodejs` | `gen/srv-qa` | hana-qa, xsuaa | QA-channel CAP srv (re-renders author drafts via `srv-qa/lib/parsers.bundle.mjs`) | -| `tutorials-approuter` | `approuter.nodejs` | `approuter/` | xsuaa, `srv-api` (destination), `srv-qa-api` (destination) | XSUAA login + static delivery + reverse proxy to both srv apps | +| Module | MTA | Type | Source | Requires | Purpose | +| --- | --- | --- | --- | --- | --- | +| `tutorials-db-deployer` | main | `hdb` | `gen/db` | `tutorials-hana`, `tutorials-cloud-logging` | Prod HANA schema + indexes (one-shot HDI deploy). Cloud Logging binding forwards deployer stdout for ~30-day forensic retention (#257). | +| `tutorials-db-qa-deployer` | main | `hdb` | `gen/db-qa` | `tutorials-hana-qa`, `tutorials-cloud-logging` | QA HANA schema (peer of `db/`, namespace `com.sap.developers.ims.qa`). Cloud Logging binding mirrors prod for QA forensic parity. | +| `tutorials-srv` | main | `nodejs` | `gen/srv` | hana, xsuaa, destination, mail, audit-log, cloud-logging, aicore | CAP backend (9 services + jobs + Socket.IO + content store + RAG); serves `/mcp/*`, `/mcp-pat/*`, `/mcp-admin/*` | +| `tutorials-srv-qa` | main | `nodejs` | `gen/srv-qa` | hana-qa, xsuaa | QA-channel CAP srv (re-renders author drafts via `srv-qa/lib/parsers.bundle.mjs`) | +| `tutorials-approuter` | main | `approuter.nodejs` | `approuter/` | xsuaa, `srv-api` (destination), `srv-qa-api` (destination), `srv-mcp-api` (destination) | XSUAA login + static delivery + reverse proxy to both srv apps AND the MCP app | +| `tutorials-srv-mcp` | **mcp** | `nodejs` | `gen/srv-mcp` | hana, xsuaa, identity (IAS), credstore, destination | Authenticated MCP backend — serves ONLY `/mcp-auth/*`; adopts the main MTA's services as existing-service | -The AppRouter routes `^/tutorials-qa/(.*)`, `^/qa-search/(.*)` to the `srv-qa-api` destination and everything else (`/api/*`, `/admin/*`, `/display/*`, `/content/*`, etc.) to `srv-api`. WebSocket paths (`^/socket\.io/`, `^/ws/`) are `authenticationType: 'none'` because the scope check happens at namespace join. +The AppRouter routes `^/tutorials-qa/(.*)`, `^/qa-search/(.*)` to the `srv-qa-api` destination, `^/mcp-auth/(.*)` to the `srv-mcp-api` destination (the external MCP app), and everything else (`/api/*`, `/admin/*`, `/display/*`, `/content/*`, `/mcp/*`, `/mcp-pat/*`, `/mcp-admin/*`, etc.) to `srv-api`. WebSocket paths (`^/socket\.io/`, `^/ws/`) are `authenticationType: 'none'` because the scope check happens at namespace join. #### BTP Service Bindings | Resource | Service / plan | Required by | Notes | | --- | --- | --- | --- | -| `tutorials-hana` | `hana` / `hdi-shared` | `tutorials-srv`, `tutorials-db-deployer` | Prod HDI container (`com.sap.xs.hdi-container`) | +| `tutorials-hana` | `hana` / `hdi-shared` | `tutorials-srv`, `tutorials-db-deployer`, `tutorials-srv-mcp` | Prod HDI container (`com.sap.xs.hdi-container`) | | `tutorials-hana-qa` | `hana` / `hdi-shared` | `tutorials-srv-qa`, `tutorials-db-qa-deployer` | QA-channel HDI container — separate from prod, no cross-foreign-keys | -| `tutorials-xsuaa` | `xsuaa` / `application` | all srv apps + approuter | Configured from [.deploy/xs-security.json](../../../.deploy/xs-security.json) (Admin, MobileApp, DisplayApp, Tutorial.Author, ConsolidationScope, DeveloperApp scopes) | -| `tutorials-destination` | `destination` / `lite` | `tutorials-srv` | NGDS + SCI remote endpoints | +| `tutorials-xsuaa` | `xsuaa` / `application` | all srv apps + approuter + `tutorials-srv-mcp` | Configured from [.deploy/xs-security.json](../../../.deploy/xs-security.json) (Admin, MobileApp, DisplayApp, Tutorial.Author, ConsolidationScope, DeveloperApp scopes) | +| `tutorials-identity` | `identity` / IAS | `tutorials-srv-mcp` | IAS public-PKCE client for `/mcp-auth/*` `mcp-remote` clients (primary auth; XSUAA is the fallback) | +| `tutorials-destination` | `destination` / `lite` | `tutorials-srv`, `tutorials-srv-mcp` | NGDS + SCI remote endpoints; the MCP app uses it for the main-srv write-forward destination | +| `tutorials-credstore` | `credstore` / `standard` | `tutorials-srv`, `tutorials-srv-mcp` | BTP Credential Store (deploy-time secrets) | | `tutorials-mail` | `mail` / `standard` | `tutorials-srv` | SMTP for notification escalation emails | | `tutorials-audit-log` | `auditlog` / `standard` (optional) | `tutorials-srv` | `@cap-js/audit-logging` sink for `@PersonalData` events | | `tutorials-cloud-logging` | `cloud-logging` / `standard` (optional) | `tutorials-srv`, `tutorials-db-deployer`, `tutorials-db-qa-deployer` | OTLP ingest enabled; backs the `cfLogsUrl` virtual on `PipelineLog` / `JobExecutionLog`. Deployer bindings (#257) capture HDI deploy stdout for ~30-day forensic retention. | | `tutorials-aicore` | `aicore` / `extended` (optional) | `tutorials-srv` | Backs `ChatService` + embeddings + RAG (`getRelevantSteps` tool) | +The MCP MTA creates **none** of these — it declares each as `org.cloudfoundry.existing-service` and adopts the instances the main MTA owns. + `optional: true` resources let `mbt build && cf deploy` succeed in a subaccount that hasn't entitled them yet (e.g., a fresh sandbox without AI Core). The srv app degrades gracefully when bindings are missing — chat returns 503, audit logging falls through to the console sink, OTLP export is no-op. #### Route Architecture diff --git a/docs/developers/operations/mcp-server.md b/docs/developers/operations/mcp-server.md index 978ea512f..a34453eb9 100644 --- a/docs/developers/operations/mcp-server.md +++ b/docs/developers/operations/mcp-server.md @@ -3,7 +3,7 @@ Operator runbook for the anonymous Model Context Protocol surface at `/mcp/*`. The CAP adapter (`@cap-js/mcp@1.1.1`) exposes selected read-only services as MCP tools; visitors' MCP clients connect through the approuter with no XSUAA round-trip. Adapter: [`@cap-js/mcp@1.1.1`](https://www.npmjs.com/package/@cap-js/mcp) — cds10-compatible; registers itself under `cds.protocols.mcp` so no `protocols` block is needed in project `package.json`. -Approuter route: `/mcp/*` → `srv-api`, `authenticationType: none`, `csrfProtection: false`. The parallel `/mcp-auth/*` prefix is **reserved for Phase 2** — do not repurpose. +Approuter route: `/mcp/*` → `srv-api`, `authenticationType: none`, `csrfProtection: false`. The parallel `/mcp-auth/*` prefix is the **authenticated** surface, routed to the separate `tutorials-srv-mcp` app (MCP MTA) via the `srv-mcp-api` destination — see [Phase 2 — the authenticated surface](#phase-2-the-authenticated-surface-shipped). Services participating: `SearchService`, `HomepageService`, `KnowledgeGraphService` — each declares `@protocol: ['odata', ..., 'mcp']`. **Never** use the `@mcp` single-protocol shortcut alone: it replaces the default OData mount (same trap as [[cap-graphql-shortcut-replaces-odata]]). ## TL;DR @@ -14,17 +14,33 @@ Services participating: `SearchService`, `HomepageService`, `KnowledgeGraphServi | Retire one tool (temporary) | Comment out the CDS function + its handler, redeploy | ~15 min | | Retire one service's whole MCP surface | Remove `'mcp'` from that service's `@protocol` list, redeploy | ~15 min | | Full MCP shutdown (ultima ratio) | `npm uninstall @cap-js/mcp`, redeploy | ~15 min | -| Roll back the last change | `cf rollback tutorials-srv` | ~1 min | +| Roll back the last change | `cf rollback tutorials-srv` (anonymous/PAT/admin) or `cf rollback tutorials-srv-mcp` (`/mcp-auth/*`) | ~1 min | ## Deploy -The adapter is a normal CAP protocol plugin — it boots with the CAP process and has no separate lifecycle. There is no MCP-specific deploy step. Use the standard MTA workflow: +The anonymous `/mcp/*`, PAT `/mcp-pat/*`, and admin `/mcp-admin/*` surfaces run inside the +main `tutorials-srv` CAP runtime. The adapter is a normal CAP protocol plugin — it boots with +that process and has no separate lifecycle. There is no MCP-specific deploy step for those +surfaces; use the standard main-MTA workflow: ```bash npm run build:all cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext -f ``` +> **The authenticated `/mcp-auth/*` surface is a SEPARATE MTA.** As of the auth-split +> (#2569/#2579/#2581) the OAuth MCP backend is the `tutorials-srv-mcp` app in its own +> `tutorials-mcp` MTA (descriptor `mta-mcp.yaml`), not `tutorials-srv`. It deploys by hand: +> +> ```bash +> mbt build -e mta-mcp.yaml -t mta_archives +> cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f # or deploy/mcp-prod.mtaext +> ``` +> +> `npm run deploy` / `scripts/deploy-mta.cjs` deploy ONLY the main MTA — there is no +> orchestrated MCP-MTA path today, so the main-MTA deploy above does NOT ship `/mcp-auth/*` +> changes. See [mta-deployment.md § MCP MTA (tutorials-mcp)](mta-deployment.md#mcp-mta-tutorials-mcp). + On boot you'll see the plugin register under `cds.protocols.mcp` and each participating service serve at `/mcp/`. Endpoints are `/mcp/search`, `/mcp/homepage`, `/mcp/graph`. ## Disable one curated tool @@ -67,10 +83,11 @@ The adapter is gone from `cds.protocols` on next boot; every `/mcp/*` request re ## Rollback -There is no MCP-specific state — no manifest, no session store, no outbox rows. Standard app rollback recovers the entire surface: +There is no MCP-specific state — no manifest, no session store, no outbox rows. Standard app rollback recovers the surface — but note the two backends roll back independently: ```bash -cf rollback tutorials-srv +cf rollback tutorials-srv # anonymous /mcp/*, PAT /mcp-pat/*, admin /mcp-admin/* +cf rollback tutorials-srv-mcp # authenticated /mcp-auth/* (separate MCP MTA) ``` If the change touched only `.cds` / `.js` files and not `db/`, this is safe to run at any time. If a schema change rode along, use the normal deploy-rollback procedure (see [mta-deployment.md](mta-deployment.md)). @@ -107,7 +124,7 @@ Boot logs show `serving { at: '/mcp/' }` for each MCP-enabled se `/mcp/*` is anonymous. If a client sees 401, the request is not hitting the anonymous route: -- Verify the URL is `/mcp/search`, `/mcp/homepage`, or `/mcp/graph`. Anything else (including `/mcp-auth/*`) is not routed to `srv-api` as anonymous — `/mcp-auth/*` is reserved for Phase 2 and currently returns 404 or 401 depending on approuter config. +- Verify the URL is `/mcp/search`, `/mcp/homepage`, or `/mcp/graph`. Anything else is not routed to `srv-api` as anonymous — notably `/mcp-auth/*` is the **authenticated** surface (separate `tutorials-srv-mcp` app) and requires an IAS/XSUAA bearer, so an anonymous `initialize` there is expected to 401. - Confirm the client isn't hitting an authenticated CAP service at a different path and appending `/mcp` to it. Only the three curated services participate. ### Connection resets after the first request @@ -122,9 +139,15 @@ The adapter picks the response encoding from the client's `Accept` header. If a No MCP-specific counters are exported yet. The general `srv-error-rate` alert on 5xx from `tutorials-srv` covers adapter-level failures — if the plugin throws on `tools/list`, that surfaces as a 500 and rolls into the standard alert. Per-tool call counts / latency histograms are a future enhancement (tracked as a nice-to-have; no issue open). -## Phase 2 preparation +## Phase 2 — the authenticated surface (shipped) -The approuter reserves `/mcp-auth/*` for the authenticated MCP surface (Phase 2 — MCP calls that require an XSUAA bearer, e.g. tools that read a user's tutorial progress). **Do not squat on this prefix** for anything else. When Phase 2 lands, the plan is to mount an OAuth-protected sibling of the current adapter under `/mcp-auth/*` while keeping the anonymous `/mcp/*` surface unchanged. +`/mcp-auth/*` is the authenticated MCP surface — MCP calls that require a bearer, e.g. tools +that read or write a user's tutorial progress. It is **live**, and since the auth-split +(#2569/#2579/#2581) it is served by the separate `tutorials-srv-mcp` app (the `tutorials-mcp` +MTA), not the main `tutorials-srv` adapter. Inbound auth is hybrid IAS (public PKCE, for +`mcp-remote` clients) with an XSUAA bearer fallback. The anonymous `/mcp/*` surface stays on +`tutorials-srv` unchanged. See [architecture/mcp-server.md](../architecture/mcp-server.md) for +the full topology. ## Phase 2 operations @@ -152,18 +175,23 @@ curl -X POST https:///pats/mintPAT \ Three Phase 2 feature flags control the MCP surface. All default to `true` (enabled). +> **Flag target depends on the surface.** `MCP_AUTH_ENABLED` gates `/mcp-auth/*`, which now +> runs in the **separate `tutorials-srv-mcp` app** (read in `srv-mcp/server.js`) — set it on +> that app. `MCP_PAT_MINT_ENABLED` and `KG_STEP_SLICER_ENABLED` gate `/mcp-pat/*` and the +> anonymous step-slicer on the **main `tutorials-srv`**. + ```bash -# Disable the authenticated MCP surface entirely -cf set-env tutorials-srv MCP_AUTH_ENABLED false && cf restart tutorials-srv +# Disable the authenticated MCP surface entirely (separate MCP MTA app) +cf set-env tutorials-srv-mcp MCP_AUTH_ENABLED false && cf restart tutorials-srv-mcp -# Disable PAT minting (existing PATs continue to work) +# Disable PAT minting (existing PATs continue to work) — main srv cf set-env tutorials-srv MCP_PAT_MINT_ENABLED false && cf restart tutorials-srv -# Disable the step-HTML slicer (get_tutorial_step returns 404 for all slugs) +# Disable the step-HTML slicer (get_tutorial_step returns 404 for all slugs) — main srv cf set-env tutorials-srv KG_STEP_SLICER_ENABLED false && cf restart tutorials-srv ``` -To restore a flag to default, `cf unset-env tutorials-srv && cf restart tutorials-srv` (unset = default `true`). +To restore a flag to default, `cf unset-env && cf restart ` (unset = default `true`) — using the same app the flag was set on above. ### Granting `Tutorials MCP Users` role collection @@ -234,7 +262,7 @@ The `sap-devs` CLI and its bundled MCP server are a **downstream consumer** of t | Consumer shape | URL | Auth | | --- | --- | --- | | Anonymous curated tools (Phase 1, unchanged) | `/mcp/search`, `/mcp/homepage`, `/mcp/graph` | none | -| Authenticated / personalized tools (browser agent) | `/mcp-auth/api` | OAuth 2.1 + PKCE via XSUAA; requires the `Tutorial.MCP` scope | +| Authenticated / personalized tools (browser agent) | `/mcp-auth/api` (separate `tutorials-srv-mcp` app) | OAuth 2.1 + PKCE via **IAS** (primary) with XSUAA fallback; requires the `Tutorial.MCP` scope on the XSUAA path | | Authenticated / personalized tools (headless CLI, CI) | `/mcp-pat/api` | `Authorization: Bearer pat_…` | `` on Dev is `https://tutorial-system-dev-tutorials-approuter.cfapps.eu10-005.hana.ondemand.com`. (Note this is the **actual CF route**, not the vanity `developers-dev.*` host — confirm the current route with `cf routes` before hardcoding.) @@ -242,7 +270,7 @@ The `sap-devs` CLI and its bundled MCP server are a **downstream consumer** of t **What the owner must do** 1. **Prefer PAT for the CLI.** A CLI is a headless agent — route personalized calls through `/mcp-pat/api` with a PAT minted at `/admin-ui/#pats`. Reserve `/mcp-auth/api` (interactive OAuth) for GUI clients like Claude Desktop. -2. **Auto-discovery.** OAuth clients read `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource` to self-configure. These are served statically by the approuter — if a client 404s on them, the approuter route order regressed (the specific static routes must precede the broad `^/.well-known/(.*)$` ORD route; guarded by `test/unit/approuter-mcp-route.test.js`). +2. **Auto-discovery.** OAuth clients read `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource` to self-configure. These are served by **runtime approuter middleware** (`insertMiddleware.first` in `approuter/server.js`, see [architecture/mcp-server.md § `.well-known` discovery](../architecture/mcp-server.md#well-known-discovery)) — values derive at request time from the bound XSUAA/IAS VCAP credentials and the request host. If a client 404s on them, the approuter middleware order regressed (the discovery middleware must run before the broad `^/.well-known/(.*)$` ORD route; guarded by `test/unit/approuter-mcp-route.test.js`). 3. **Scope grant.** Interactive OAuth users need the `Tutorials MCP Users` role collection (grants `Tutorial.MCP`) — see [Granting `Tutorials MCP Users` role collection](#granting-tutorials-mcp-users-role-collection) above. PAT callers do **not** need the XSUAA scope; the PAT's own `scopes` array (`read` / `write`) governs access. 4. **Do not depend on `@sap/`-internal MCP behavior.** The adapter is the public `@cap-js/mcp`; tool names and the `/mcp*` namespaces are stable across Phase 2→3 (Phase 3 only *adds* tools). Client configs will not need to change on the Phase 3 rollout. 5. **Rate limits & failure modes.** Both authenticated paths converge on the same CAP handlers and rate limits as the anonymous surface — see [Rate limiting](#rate-limiting) and [Common failures](#common-failures). An expired/revoked PAT returns 401. @@ -299,4 +327,6 @@ The `mcp_compose_fallback_total` counter is the canary for the adapter deep-impo - Adapter: [`@cap-js/mcp` on npm](https://www.npmjs.com/package/@cap-js/mcp) - Related memory: [[cap-graphql-shortcut-replaces-odata]] — why `@protocol:` must be a list, not a shortcut -- Related runbook: [mta-deployment.md](mta-deployment.md) — standard deploy path +- Related runbook: [mta-deployment.md](mta-deployment.md) — standard deploy path, incl. [§ MCP MTA (tutorials-mcp)](mta-deployment.md#mcp-mta-tutorials-mcp) +- MCP MTA descriptors: `mta-mcp.yaml` (root), `deploy/mcp-dev.mtaext`, `deploy/mcp-prod.mtaext` +- Architecture: [architecture/mcp-server.md](../architecture/mcp-server.md) — two-MTA topology, route-to-backend map diff --git a/docs/developers/operations/mta-deployment.md b/docs/developers/operations/mta-deployment.md index ed708fb4e..ff54844c2 100644 --- a/docs/developers/operations/mta-deployment.md +++ b/docs/developers/operations/mta-deployment.md @@ -4,21 +4,35 @@ Complete deployment procedure for standing up the tutorials-ims stack in a CF sp ## Architecture +The stack is **two separately-deployed MTAs** sharing one set of service instances: + ```text -tutorials-ims MTA -├── tutorials-db-deployer (type: hdb — deploys CDS schema to HDI container) -├── tutorials-srv (type: nodejs — CAP OData/REST backend) -└── tutorials-approuter (type: approuter.nodejs — serves static UI + proxies to srv) +tutorials-ims MTA (.deploy/mta.yaml — the main MTA; OWNS the service instances) +├── tutorials-db-deployer (type: hdb — deploys CDS schema to HDI container) +├── tutorials-db-qa-deployer (type: hdb — QA channel HDI container) +├── tutorials-srv (type: nodejs — CAP backend; serves /mcp/*, /mcp-pat/*, /mcp-admin/*) +├── tutorials-srv-qa (type: nodejs — QA channel srv) +└── tutorials-approuter (type: approuter.nodejs — static UI + proxies to srv AND to the MCP app) + +tutorials-mcp MTA (mta-mcp.yaml at repo root — the MCP microservice; ADOPTS the services above) +└── tutorials-srv-mcp (type: nodejs — serves ONLY the authenticated /mcp-auth/* surface) -Resources (created by MTA): +Resources (created/owned by the MAIN MTA; the MCP MTA references them as existing-service): ├── tutorials-hana (HDI container — com.sap.xs.hdi-container) ├── tutorials-xsuaa (XSUAA application — org.cloudfoundry.managed-service) +├── tutorials-identity (IAS — public PKCE client for the MCP app) ├── tutorials-destination (Destination lite — org.cloudfoundry.managed-service) +├── tutorials-credstore (Credential Store — holds deploy-time secrets) ├── tutorials-mail (optional — fails gracefully if plan unavailable) ├── tutorials-audit-log (optional) └── tutorials-cloud-logging (optional) ``` +The approuter (main MTA) fronts both backends: `/mcp-auth/*` is proxied to the external +`tutorials-srv-mcp` app via the `srv-mcp-api` destination (`${srv-mcp-url}`), everything else +to the local `srv-api`. The MCP MTA creates **no** service instances of its own — it declares +each resource as `org.cloudfoundry.existing-service` and adopts the main MTA's instances. + ## Prerequisites - CF CLI logged in to target org/space @@ -37,10 +51,11 @@ Resources (created by MTA): ```bash cd .deploy mbt build -cf deploy mta_archives/tutorials-ims_1.0.0.mtar -e ../deploy/dev.mtaext +cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext ``` -The `dev.mtaext` extension adds DEV-specific overrides (debug logging, approuter name suffix, expose CAP UI). For production, omit `-e` or provide a prod extension. +The mtar filename carries the version from `.deploy/mta.yaml` (e.g. +`tutorials-ims_1.33.0.mtar`); the `*.mtar` glob avoids pinning a stale version here. The `dev.mtaext` extension adds DEV-specific overrides (debug logging, approuter name suffix, expose CAP UI). For production, omit `-e` or provide a prod extension. ### Canonical app names per environment @@ -66,6 +81,34 @@ The `cutover-rehearsal.cjs` orchestrator's Step 1 also asserts this invariant **First deploy** takes ~10 minutes (HDI container creation + full schema deploy). Subsequent deploys are incremental. +### MCP MTA (`tutorials-mcp`) + +The authenticated MCP surface (`/mcp-auth/*`) ships as a **separate MTA** (`mta-mcp.yaml` at +repo root, module `tutorials-srv-mcp`). It is **not** part of the main MTA archive and the +main-MTA deploy above does NOT ship `/mcp-auth/*` changes. There is **no orchestrated path** — +`npm run deploy` / `scripts/deploy-mta.cjs` deploy only the main MTA. Deploy the MCP MTA by +hand: + +```bash +# From repo root (the descriptor's before-all runs from root, no `cd ..`) +mbt build -e mta-mcp.yaml -t mta_archives +cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f # DEV +# PROD: cf deploy mta_archives/*.mtar -e deploy/mcp-prod.mtaext -f +``` + +| Env | MCP MTA ext | MTA ID (ext) | Notes | +| --- | --- | --- | --- | +| dev | `deploy/mcp-dev.mtaext` | `tutorials-mcp-dev` | Overrides `dest-main-srv` → `tutorials-main-srv-api-dev` (DEV write-forward destination) | +| prod | `deploy/mcp-prod.mtaext` | `tutorials-mcp-prod` | Overrides `tutorials-xsuaa` → `service-name: xsuaa-imsprod` (PROD XSUAA, xsappname `tutorials-prod`) | + +**Service adoption:** every `resources:` entry in `mta-mcp.yaml` is +`org.cloudfoundry.existing-service` (`tutorials-identity`, `tutorials-xsuaa`, +`tutorials-hana`, `tutorials-credstore`, `tutorials-destination`). The MCP MTA creates no +instances — it binds the ones the **main MTA owns**. Deploy the main MTA first on a fresh +space so those instances exist before the MCP MTA tries to adopt them. The approuter-side +discovery/routing env (`srv-mcp-url`, `XSUAA_MCP_URL`, `MCP_ISSUER_KIND`) stays in the +**main** MTA's `deploy/*.mtaext` (the approuter lives in the main MTA). + ## Step 2: Populate Data (First Deploy Only) After a fresh HDI deploy, mission/group slugs are empty. The build pipeline needs slugs to generate pages. @@ -152,10 +195,12 @@ curl -s -o /dev/null -w "%{http_code}" "$APPROUTER/tutorials/abap-dev-get-starte | File | Purpose | |------|---------| -| `.deploy/mta.yaml` | Main MTA deployment descriptor | -| `deploy/dev.mtaext` | DEV-specific extension (debug logging, app name suffix) | +| `.deploy/mta.yaml` | Main MTA (`tutorials-ims`) deployment descriptor | +| `mta-mcp.yaml` | MCP MTA (`tutorials-mcp`) descriptor — the separate `/mcp-auth/*` app | +| `deploy/dev.mtaext` | Main-MTA DEV extension (debug logging, app name suffix, `srv-mcp-url`) | +| `deploy/mcp-dev.mtaext` / `deploy/mcp-prod.mtaext` | MCP-MTA env extensions | | `xs-security.json` | XSUAA scopes, role templates, role collections | -| `approuter/xs-app.json` | AppRouter route definitions | +| `approuter/xs-app.json` | AppRouter route definitions (incl. the `/mcp-auth/*` → `srv-mcp-api` route) | | `.cdsrc-private.json` | Local hybrid bindings (gitignored) | ## Parallel Operation with Legacy IMS @@ -208,6 +253,13 @@ The rename touches **two** identity domains that Cloud Foundry tracks independen | MTA `ID:` | `tutorials-poc` → `tutorials-ims` in `mta.yaml` and `.deploy/mta.yaml` | CF creates a NEW MTA registration. Service instances are NOT recreated because every `resources:` entry declares an explicit `service-name:` — the deployer adopts the existing `tutorials-hana`, `tutorials-xsuaa`, `tutorials-credstore`, etc. | | `xsappname` | `tutorials-poc` → `tutorials-ims` in `xs-security.json` and `.deploy/xs-security.json` | XSUAA service instance is **updated** (not recreated) — `cf update-service tutorials-xsuaa -c xs-security.json` flips the scope namespace. Every issued JWT scope literal changes from `tutorials-poc!t.*` to `tutorials-ims!t.*`. Existing role-collection bindings become empty (they reference scopes that no longer exist) and must be re-bound. | +> **MCP-MTA note (post-split).** The `tutorials-poc` → `tutorials-ims` cutover below predates +> the MCP split and touched only the main MTA. For any **future** rename of a shared service +> (xsuaa/hana/credstore), remember the separately-deployed `tutorials-mcp` MTA adopts those +> same instances by `service-name:` — after the main-MTA rename lands, redeploy the MCP MTA +> (`cf deploy … -e deploy/mcp-.mtaext`) so `tutorials-srv-mcp` re-binds the renamed +> instances. + ### What is and isn't safe **Safe (data preserved):** @@ -356,7 +408,7 @@ Same procedure, scheduled into the end-of-July 2026 AEM→IMS PROD window. Diffe ### Why not `cf undeploy --delete-services` first (as the issue originally specified)? -The issue's original runbook assumed renaming the MTA `ID:` forced full service-instance recreation. That's only true when `resources:` entries lack explicit `service-name:` declarations. **This codebase already declares `service-name:` on every resource** (see `.deploy/mta.yaml:211-289`), which decouples instance identity from MTA identity. The `--delete-services` flag would actively destroy HANA schemas, force a fresh HDI deploy, and require restoring data from snapshot — the destructive path is exactly what `service-name:` is designed to avoid. +The issue's original runbook assumed renaming the MTA `ID:` forced full service-instance recreation. That's only true when `resources:` entries lack explicit `service-name:` declarations. **This codebase already declares `service-name:` on every resource** (see the `resources:` block in `.deploy/mta.yaml`), which decouples instance identity from MTA identity. The `--delete-services` flag would actively destroy HANA schemas, force a fresh HDI deploy, and require restoring data from snapshot — the destructive path is exactly what `service-name:` is designed to avoid. ### Post-cutover lessons (2026-07-03 DEV cutover incident) diff --git a/docs/developers/operations/production-ready.md b/docs/developers/operations/production-ready.md index 2715d28c2..3a4c04894 100644 --- a/docs/developers/operations/production-ready.md +++ b/docs/developers/operations/production-ready.md @@ -80,7 +80,7 @@ resources: Then deploy with: ```bash -cf deploy mta_archives/tutorials-ims_1.0.0.mtar -e deploy/prod.mtaext +cf deploy mta_archives/*.mtar -e deploy/prod.mtaext ``` --- diff --git a/docs/developers/operations/qa-channel-bootstrap.md b/docs/developers/operations/qa-channel-bootstrap.md index 3e9e49c8f..be3d5bcb2 100644 --- a/docs/developers/operations/qa-channel-bootstrap.md +++ b/docs/developers/operations/qa-channel-bootstrap.md @@ -74,7 +74,7 @@ Without the QA build, `static/qa/` will be empty after `mbt build` and `/tutoria ```bash cd .deploy mbt build -cf deploy mta_archives/tutorials-ims_1.0.0.mtar -e ../deploy/dev.mtaext +cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext ``` Both `tutorials-srv` and `tutorials-srv-qa` apps should be healthy; HDI containers `tutorials-hana` and `tutorials-hana-qa` deployed. diff --git a/docs/end-users/mcp-quickstart.md b/docs/end-users/mcp-quickstart.md index 41277e2d7..16c55129d 100644 --- a/docs/end-users/mcp-quickstart.md +++ b/docs/end-users/mcp-quickstart.md @@ -161,7 +161,7 @@ The server speaks Streamable HTTP. Clients may send `Accept: application/json` o ## Troubleshooting -- **`initialize` returns 401.** You hit `/mcp-auth/*` by mistake. That namespace is reserved for Phase 2 authenticated tools; use `/mcp/*` in Phase 1. +- **`initialize` returns 401.** You hit `/mcp-auth/*`, the **authenticated** surface — it needs an OAuth bearer (IAS/XSUAA). For the anonymous tools use `/mcp/*` (`/mcp/search`, `/mcp/homepage`, `/mcp/graph`). - **`tools/list` returns an empty array.** Wrong service path. Each service is mounted separately — `/mcp/search`, `/mcp/homepage`, `/mcp/graph`. There is no aggregate `/mcp` root. - **First response is slow (5–15s).** Cold start. The CAP backend spins down when idle. Subsequent requests are fast. - **Nothing returns and no error.** Anonymous IP throttle. Back off and retry. diff --git a/docs/pilot-status.md b/docs/pilot-status.md index c5476a02d..810335a93 100644 --- a/docs/pilot-status.md +++ b/docs/pilot-status.md @@ -63,7 +63,7 @@ This is the locked scope. Anything not listed is post-launch. ### Deployment -- Single MTA deployment via [../.deploy/mta.yaml](../.deploy/mta.yaml) → SAP BTP Cloud Foundry (eu10-005, DevRel and Community Tools subaccount); see [mta-deployment.md](mta-deployment.md) +- Two MTAs → SAP BTP Cloud Foundry (eu10-005, DevRel and Community Tools subaccount): the main [../.deploy/mta.yaml](../.deploy/mta.yaml) plus the separate MCP MTA [../mta-mcp.yaml](../mta-mcp.yaml) (`/mcp-auth/*`); see [mta-deployment.md](mta-deployment.md) - AppRouter + XSUAA + 2 HDI containers (prod + QA) - 3-tier testing: unit (in-memory SQLite), hybrid (real HANA via `cds bind --exec`), smoke (HTTP against deployed) - CI: [../.github/workflows/deploy.yml](../.github/workflows/deploy.yml), [../.github/workflows/rebuild-content.yml](../.github/workflows/rebuild-content.yml) From b040ee0b96291522beaa090b23a889e886126aeb Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 14:10:22 -0400 Subject: [PATCH 14/46] feat(auth): themed two-button sign-in Hugo layout (#2506) --- hugo/layouts/signin.html | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 hugo/layouts/signin.html diff --git a/hugo/layouts/signin.html b/hugo/layouts/signin.html new file mode 100644 index 000000000..484df46d2 --- /dev/null +++ b/hugo/layouts/signin.html @@ -0,0 +1,56 @@ +{{ define "main" }} + + + +{{- /* ui5Split: illustration assets for , mirrors 403.html (#1777 Task 7) */}} +{{ if and (not site.Params.previewMode) site.Params.ui5Split }}{{ end }} +{{ end }} From 503a96ff428ce245ed6e611a1a5688a7425841fc Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 14:11:52 -0400 Subject: [PATCH 15/46] docs(2595): document blockquote alert callout syntax for authors --- docs/authors/writing-tutorials.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/authors/writing-tutorials.md b/docs/authors/writing-tutorials.md index 9dd4c3b9d..72a6f80cf 100644 --- a/docs/authors/writing-tutorials.md +++ b/docs/authors/writing-tutorials.md @@ -280,6 +280,31 @@ for one OS, then ask the extension to "generate OS variants" — it returns tran macOS / Linux / BAS blocks you can review and accept inline. See the [VS Code extension docs](TODO: link added when plugin ships) for the workflow. +### Alert callouts + +A plain blockquote (`>`) renders as the default blue **Information** callout. +To signal a different kind of emphasis, start the blockquote with a +GitHub-style alert designator on its own first line: + +```markdown +> [!TIP] +> Prefer the frontmatter `video:` field over an inline iframe. +``` + +| You write | Rendered emphasis | Color | +|---|---|---| +| `> text` (no designator) | Information (default) | blue | +| `> [!NOTE]` | Information | blue | +| `> [!IMPORTANT]` | Information | blue | +| `> [!TIP]` | Success | green | +| `> [!WARNING]` | Warning | amber | +| `> [!CAUTION]` | Error | red | + +Pick the designator by *intent*, not loudness: use `[!TIP]` for helpful +asides, `[!WARNING]`/`[!CAUTION]` only for genuine risk. An ordinary +"additional note" can stay a plain `>` (blue) or use `[!NOTE]`. The callout is +color-only — there is no icon or heading label. + ### 3.6 Code blocks Use fenced code blocks with a language tag. Supported languages include `abap`, `js`, `ts`, `java`, `cds`, `sql`, `bash`, `yaml`, `json`, `xml`, `html`, `css`. CDS code uses dedicated highlighting (see `npm run build:highlight`). From 5030da4c97f81e1bcc4bb61fdf74672d8fcdc3ea Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 2 Oct 2026 14:14:33 -0400 Subject: [PATCH 16/46] =?UTF-8?q?content:=20rename=20SAP=20BTP=20=E2=86=92?= =?UTF-8?q?=20SAP=20Business=20AI=20Platform=20in=20user-facing=20strings?= =?UTF-8?q?=20(#2588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'SAP Business Technology Platform' / 'SAP BTP' / 'BTP' name is retired in favour of 'SAP Business AI Platform' (never abbreviated 'BAIP'). Renames user-facing prose in this repo's own authored content only: Hugo site/layout strings, Joule starter prompts, admin-UI help/labels, event-stream ticker labels, api-docs.yaml service titles/descriptions, and the KTT acronym-quiz content. The BTP quiz entry now teaches the retired name; the existing BAIP entry is kept (abbreviation permitted only inside the three-letter-acronym quiz). Technical identifiers are intentionally left untouched: the btp CLI, AICore-btp binding, BTP_ROLES_* env vars, cockpit.btp.cloud.sap URLs, sourceId/category identifiers, CSS --btp-* tokens, tag slugs, package names. Out of scope (per issue triage): generated/overwritten content (hugo/content/tutorials/, public/, caches), generated hugo/data/*.json, internal docs/, and reader-facing tutorial body prose (sourced from the external sap-tutorials/* repos — needs separate upstream PRs). --- app/admin-shell/webapp/model/navigation.json | 2 +- app/admin/joule/webapp/i18n/i18n.properties | 4 +- app/admin/joule/webapp/view/Settings.view.xml | 2 +- .../puzzles/webapp/view/Builder.view.xml | 2 +- app/admin/secrets/webapp/i18n/i18n.properties | 2 +- .../webapp/view/SecretDialog.fragment.xml | 2 +- app/display-app/src/event-stream.ts | 2 +- db/data/api-docs.yaml | 60 ++++++++-------- hugo-apps/src/event-display/useEventStream.ts | 2 +- hugo/content/api-docs/_index.md | 2 +- hugo/content/api-docs/graphql/_index.md | 4 +- hugo/content/cookies.md | 2 +- hugo/content/operate/_index.md | 2 +- hugo/content/u9-glossary-demo.md | 12 ++-- hugo/hugo.toml | 2 +- hugo/layouts/_default/llms.txt | 2 +- hugo/layouts/_default/sitemapmd.md | 2 +- hugo/layouts/_default/terms.html | 2 +- hugo/layouts/explore/about.html | 4 +- hugo/layouts/partials/joule-starters.html | 20 +++--- .../verb-extras/btp-service-catalog.html | 4 +- hugo/static/AGENTS.md | 4 +- scripts/generate-ktt-lessons.ts | 68 +++++++++---------- 23 files changed, 105 insertions(+), 103 deletions(-) diff --git a/app/admin-shell/webapp/model/navigation.json b/app/admin-shell/webapp/model/navigation.json index 2612410f8..5dce31908 100644 --- a/app/admin-shell/webapp/model/navigation.json +++ b/app/admin-shell/webapp/model/navigation.json @@ -154,7 +154,7 @@ "requiredScope": "Admin", "items": [ { "key": "baipAiLaunchpad", "title": "AI Launchpad", "href": "https://tutorial-system.ai-launchpad.prod.eu-central-1.aws.ai-prod.cloud.sap/aic/index.html", "target": "_blank", "requiredScope": "Admin" }, - { "key": "baipBtpSubaccount", "title": "BTP Subaccount", "href": "https://emea.cockpit.btp.cloud.sap/cockpit/#/globalaccount/a0ec0d63-f690-42ab-8113-9f6567cd897e/subaccount/3c6fa3f1-db8c-4e47-9048-fa8c84b867cb/subaccountoverview", "target": "_blank", "requiredScope": "Admin" }, + { "key": "baipBtpSubaccount", "title": "SAP Business AI Platform Subaccount", "href": "https://emea.cockpit.btp.cloud.sap/cockpit/#/globalaccount/a0ec0d63-f690-42ab-8113-9f6567cd897e/subaccount/3c6fa3f1-db8c-4e47-9048-fa8c84b867cb/subaccountoverview", "target": "_blank", "requiredScope": "Admin" }, { "key": "baipHanaCloud", "title": "HANA Cloud", "href": "https://devrel.hana-tooling.ingress.orchestration.prod-eu10.hanacloud.ondemand.com/hcs/sap/hana/cloud/index.html", "target": "_blank", "requiredScope": "Admin" }, { "key": "baipIasAdminConsole", "title": "IAS Admin Console", "href": "https://atxgsg7zi.accounts.ondemand.com/admin/#/applications/17b636dd-fa87-4cd8-b0f2-0abd5f9a3042", "target": "_blank", "requiredScope": "Admin" } ] diff --git a/app/admin/joule/webapp/i18n/i18n.properties b/app/admin/joule/webapp/i18n/i18n.properties index ad2610c07..9cc21f8f7 100644 --- a/app/admin/joule/webapp/i18n/i18n.properties +++ b/app/admin/joule/webapp/i18n/i18n.properties @@ -72,6 +72,6 @@ a2aTokenUrlPlaceholder=XSUAA token endpoint advertised in the Agent Card alertsSection=Operational Alerting (SAP Alert Notification) alertsEnabled=Enable ANS push alerts -alertsEnabledHelp=Master switch for SAP Alert Notification push alerts (publish-reject, scheduled-job failures, rebuild-dispatch failures). Off (default) = no alerts sent. Requires the ANS email action to be configured in the BTP cockpit first. DB-backed — takes effect live, no redeploy needed. +alertsEnabledHelp=Master switch for SAP Alert Notification push alerts (publish-reject, scheduled-job failures, rebuild-dispatch failures). Off (default) = no alerts sent. Requires the ANS email action to be configured in the SAP Business AI Platform cockpit first. DB-backed — takes effect live, no redeploy needed. sendTestAlertButton=Send test alert -sendTestAlertHelp=Fires a TEST alert (eventType AlertingTest) through the real ANS code path so you can confirm end-to-end delivery on demand. Enable ANS push alerts above and click Save FIRST — the toggle only takes effect after saving (a ~5s server cache). A delivered result means the code path fired; the email also needs an AlertingTest condition wired in the BTP cockpit for the target environment. +sendTestAlertHelp=Fires a TEST alert (eventType AlertingTest) through the real ANS code path so you can confirm end-to-end delivery on demand. Enable ANS push alerts above and click Save FIRST — the toggle only takes effect after saving (a ~5s server cache). A delivered result means the code path fired; the email also needs an AlertingTest condition wired in the SAP Business AI Platform cockpit for the target environment. diff --git a/app/admin/joule/webapp/view/Settings.view.xml b/app/admin/joule/webapp/view/Settings.view.xml index 39b617e1e..944ab7f88 100644 --- a/app/admin/joule/webapp/view/Settings.view.xml +++ b/app/admin/joule/webapp/view/Settings.view.xml @@ -191,7 +191,7 @@ scheduled-job failures, and rebuild-dispatch failures (srv/lib/alerting.js, resolved via srv/lib/runtime-config/alert-settings.js). DB-backed (no env var) so ops can toggle live without a restart. The ANS - email action must be configured in the BTP cockpit first, else alerts + email action must be configured in the SAP Business AI Platform cockpit first, else alerts have nowhere to go. Rollback by flipping this back off — no redeploy. --> diff --git a/app/admin/puzzles/webapp/view/Builder.view.xml b/app/admin/puzzles/webapp/view/Builder.view.xml index 4b10b48c5..01355a355 100644 --- a/app/admin/puzzles/webapp/view/Builder.view.xml +++ b/app/admin/puzzles/webapp/view/Builder.view.xml @@ -53,7 +53,7 @@