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" }}
+
+
+
+ Sign in to SAP Developers
+
+ Choose how you'd like to sign in. Your progress and completions are the
+ same account whichever you pick.
+
+
+
+
+
+
+
+{{- /* 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 @@
-
+
diff --git a/app/admin/secrets/webapp/i18n/i18n.properties b/app/admin/secrets/webapp/i18n/i18n.properties
index 89e94c863..d7695cda1 100644
--- a/app/admin/secrets/webapp/i18n/i18n.properties
+++ b/app/admin/secrets/webapp/i18n/i18n.properties
@@ -1,7 +1,7 @@
appTitle=Secrets Inventory
pageTitle=Secrets Inventory
tableTitle=Tracked Secrets
-infoStrip=Encrypted values are stored in the BTP Credential Store and read at runtime by tutorials-srv. Use "Set value" / "Rotate" / "Clear" to manage. The cached read-token TTL is 5 min, but changes here flush the cache immediately. Daily expiry-check cron at 04:11 UTC surfaces warnings via the notifications popover (bell icon).
+infoStrip=Encrypted values are stored in the SAP Business AI Platform Credential Store and read at runtime by tutorials-srv. Use "Set value" / "Rotate" / "Clear" to manage. The cached read-token TTL is 5 min, but changes here flush the cache immediately. Daily expiry-check cron at 04:11 UTC surfaces warnings via the notifications popover (bell icon).
colKey=Key
colValue=Value
colKind=Kind
diff --git a/app/admin/secrets/webapp/view/SecretDialog.fragment.xml b/app/admin/secrets/webapp/view/SecretDialog.fragment.xml
index d777cc0c6..6cc200360 100644
--- a/app/admin/secrets/webapp/view/SecretDialog.fragment.xml
+++ b/app/admin/secrets/webapp/view/SecretDialog.fragment.xml
@@ -18,7 +18,7 @@
-
+
diff --git a/app/display-app/src/event-stream.ts b/app/display-app/src/event-stream.ts
index e1115e2f0..19019ba5d 100644
--- a/app/display-app/src/event-stream.ts
+++ b/app/display-app/src/event-stream.ts
@@ -15,7 +15,7 @@ export interface RecentEvent {
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'
const DEMO_TAGS = [
- 'SAP BTP', 'SAP HANA Cloud', 'SAP Fiori', 'SAP Build',
+ 'SAP Business AI Platform', 'SAP HANA Cloud', 'SAP Fiori', 'SAP Build',
'SAP Integration Suite', 'SAP Analytics Cloud', 'ABAP Cloud',
'SAP AI Core', 'SAP CAP', 'SAP Mobile',
]
diff --git a/db/data/api-docs.yaml b/db/data/api-docs.yaml
index b152414ed..c0b027da4 100644
--- a/db/data/api-docs.yaml
+++ b/db/data/api-docs.yaml
@@ -6,7 +6,7 @@
# Coverage matrix (per spec §7.1):
# - CAP (8) — framework reference + cloud-sdk + cds
# - S/4HANA Cloud (15) — public OData v2 APIs (Business Partner, Sales Order, etc.)
-# - BTP services (15) — Connectivity, Destination, Identity Authentication, Audit Log, etc.
+# - SAP Business AI Platform services (15) — Connectivity, Destination, Identity Authentication, Audit Log, etc.
# - SAP Build / Workflow / Process Automation (4)
# - Integration Suite (5) — Cloud Integration, Open Connectors, API Management
# - Cloud SDK (4) — Java + JavaScript
@@ -242,32 +242,32 @@
apiType: odata-v2
# ============================================================================
-# BTP Services
+# SAP Business AI Platform Services
# ============================================================================
- sourceId: SAP_Cloud_Connector_Connectivity
- title: SAP BTP — Connectivity Service API
+ title: SAP Business AI Platform — Connectivity Service API
url: https://api.sap.com/package/SAPBTPConnectivityService
description: |
- REST API and SDK for the SAP BTP Connectivity Service. Manage Cloud
+ REST API and SDK for the SAP Business AI Platform Connectivity Service. Manage Cloud
Connector tunnels, on-premise system mappings, and principal propagation
configuration. Used to reach S/4HANA or other on-premise systems from
- BTP-hosted applications.
+ SAP Business AI Platform-hosted applications.
category: BTP
apiType: rest
- sourceId: SAP_BTP_Destination_Service
- title: SAP BTP — Destination Service API
+ title: SAP Business AI Platform — Destination Service API
url: https://api.sap.com/package/SAPBTPDestinationService
description: |
- REST API for the SAP BTP Destination Service. Configure HTTP, RFC, or
+ REST API for the SAP Business AI Platform Destination Service. Configure HTTP, RFC, or
MAIL destinations with authentication tokens, certificates, and
location-based routing. Consumed by approuter and CAP applications.
category: BTP
apiType: rest
- sourceId: SAP_XSUAA_Authorization
- title: SAP BTP — XSUAA Authentication & Authorization
+ title: SAP Business AI Platform — XSUAA Authentication & Authorization
url: https://api.sap.com/package/SAPXSUAA
description: |
Reference for the XSUAA service (Cloud Foundry UAA fork). Covers JWT
@@ -282,62 +282,62 @@
description: |
REST API for the SAP Cloud Identity Services — Identity Authentication
(IAS). Manage users, groups, SAML/OIDC trust configurations, and
- branding. Used as the primary IdP for SAP BTP applications.
+ branding. Used as the primary IdP for SAP Business AI Platform applications.
category: BTP
apiType: rest
- sourceId: SAP_BTP_AuditLog
- title: SAP BTP — Audit Log Service
+ title: SAP Business AI Platform — Audit Log Service
url: https://api.sap.com/package/SAPAuditLogService
description: |
- REST API for the SAP BTP Audit Log Service. Write security events,
+ REST API for the SAP Business AI Platform Audit Log Service. Write security events,
personal-data accesses, and configuration changes. Reads via the Audit
Log Retrieval API.
category: BTP
apiType: rest
- sourceId: SAP_BTP_Credstore
- title: SAP BTP — Credential Store Service
+ title: SAP Business AI Platform — Credential Store Service
url: https://api.sap.com/package/SAPCredstore
description: |
- REST API for the SAP BTP Credential Store. Securely store, rotate,
+ REST API for the SAP Business AI Platform Credential Store. Securely store, rotate,
and retrieve secrets (passwords, keys, OAuth tokens) for applications
bound to a credstore service instance. Supports JWE-encrypted payloads.
category: BTP
apiType: rest
- sourceId: SAP_BTP_AlertNotification
- title: SAP BTP — Alert Notification Service
+ title: SAP Business AI Platform — Alert Notification Service
url: https://api.sap.com/package/SAPAlertNotificationService
description: |
- REST API for the SAP BTP Alert Notification Service. Send operational
+ REST API for the SAP Business AI Platform Alert Notification Service. Send operational
events to channels (email, Slack, webhook, ServiceNow) with subscription
+ filter management.
category: BTP
apiType: rest
- sourceId: SAP_BTP_FeatureFlags
- title: SAP BTP — Feature Flags Service
+ title: SAP Business AI Platform — Feature Flags Service
url: https://api.sap.com/package/SAPFeatureFlagsService
description: |
- REST API for the SAP BTP Feature Flags service. Define boolean and
+ REST API for the SAP Business AI Platform Feature Flags service. Define boolean and
string-typed flags with rollout rules; consume via the SAP Cloud SDK
or direct REST.
category: BTP
apiType: rest
- sourceId: SAP_BTP_BusinessRules
- title: SAP BTP — Business Rules Service
+ title: SAP Business AI Platform — Business Rules Service
url: https://api.sap.com/package/SAPBusinessRulesService
description: |
- REST API for the SAP BTP Business Rules service. Author decision
+ REST API for the SAP Business AI Platform Business Rules service. Author decision
tables and rule sets in the rules editor; execute via runtime API
from CAP or other applications.
category: BTP
apiType: rest
- sourceId: SAP_BTP_DocumentInformation
- title: SAP BTP — Document Information Extraction
+ title: SAP Business AI Platform — Document Information Extraction
url: https://api.sap.com/package/SAPDocumentInformationExtraction
description: |
REST API for the Document Information Extraction service. Submit PDFs
@@ -347,19 +347,19 @@
apiType: rest
- sourceId: SAP_BTP_DocumentClassification
- title: SAP BTP — Document Classification
+ title: SAP Business AI Platform — Document Classification
url: https://api.sap.com/package/SAPDocumentClassification
description: |
- REST API for the SAP BTP Document Classification service. Submit
+ REST API for the SAP Business AI Platform Document Classification service. Submit
documents for ML-driven classification into custom-trained categories.
category: BTP
apiType: rest
- sourceId: SAP_BTP_PrintService
- title: SAP BTP — Print Service
+ title: SAP Business AI Platform — Print Service
url: https://api.sap.com/package/SAPPrintService
description: |
- REST API for the SAP BTP Print Service. Submit print jobs to network
+ REST API for the SAP Business AI Platform Print Service. Submit print jobs to network
printers via SAP-managed queue infrastructure.
category: BTP
apiType: rest
@@ -374,17 +374,17 @@
apiType: rest
- sourceId: SAP_BTP_Mobile_Services
- title: SAP BTP — Mobile Services API
+ title: SAP Business AI Platform — Mobile Services API
url: https://api.sap.com/package/SAPMobileServices
description: |
- REST API and SDK for SAP BTP Mobile Services. Manage mobile app
+ REST API and SDK for SAP Business AI Platform Mobile Services. Manage mobile app
registrations, push-notification dispatch, offline OData sync, and
feature flags scoped per-app.
category: BTP
apiType: rest
- sourceId: SAP_BTP_HtmlSrv
- title: SAP BTP — HTML5 Application Repository Service
+ title: SAP Business AI Platform — HTML5 Application Repository Service
url: https://api.sap.com/package/SAPHTMLApplicationRepositoryService
description: |
REST API for managing static HTML5 application bundles deployed via
@@ -422,7 +422,7 @@
description: |
Reference for the SAP Build Apps (formerly AppGyver) runtime. Covers
Composer Pro logic, data resources, REST integration patterns, and
- deployment via SAP BTP.
+ deployment via SAP Business AI Platform.
category: SAP Build
apiType: reference
@@ -474,7 +474,7 @@
description: |
REST and AMQP 1.0 API for SAP Event Mesh. Publish events on topics,
create durable queues, manage subscriptions, and consume via push or
- pull. Foundation for event-driven architecture on BTP.
+ pull. Foundation for event-driven architecture on SAP Business AI Platform.
category: Integration Suite
apiType: rest
@@ -584,7 +584,7 @@
url: https://api.sap.com/package/SAPJouleAgent
description: |
Reference for the SAP Joule agent framework. Build composite agents
- that orchestrate skills, retrieve context from BTP services, and
+ that orchestrate skills, retrieve context from SAP Business AI Platform services, and
converse with users via the Joule shell or embedded experiences.
category: AI
apiType: reference
diff --git a/hugo-apps/src/event-display/useEventStream.ts b/hugo-apps/src/event-display/useEventStream.ts
index 098c5bb17..cc839b23e 100644
--- a/hugo-apps/src/event-display/useEventStream.ts
+++ b/hugo-apps/src/event-display/useEventStream.ts
@@ -10,7 +10,7 @@ export interface Bucket {
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'
const DEMO_TAGS = [
- 'SAP BTP', 'SAP HANA Cloud', 'SAP Fiori', 'SAP Build',
+ 'SAP Business AI Platform', 'SAP HANA Cloud', 'SAP Fiori', 'SAP Build',
'SAP Integration Suite', 'SAP Analytics Cloud', 'ABAP Cloud',
'SAP AI Core', 'SAP CAP', 'SAP Mobile',
]
diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md
index f6d375437..f42df1299 100644
--- a/hugo/content/api-docs/_index.md
+++ b/hugo/content/api-docs/_index.md
@@ -32,7 +32,7 @@ These exist and are documented, but require elevated XSUAA scopes (`Tutorial.Aut
## sap-devs CLI
-`sap-devs` is a companion CLI that bundles the same SAP developer content and context this site consumes — CAP / BTP / ABAP tips, canonical code samples, error lookups, tutorial search, event listings, and more — so you can get to it without leaving your terminal.
+`sap-devs` is a companion CLI that bundles the same SAP developer content and context this site consumes — CAP / SAP Business AI Platform / ABAP tips, canonical code samples, error lookups, tutorial search, event listings, and more — so you can get to it without leaving your terminal.
**Install** — the CLI ships as a self-contained binary (it's no longer published to npm). Pick your platform:
diff --git a/hugo/content/api-docs/graphql/_index.md b/hugo/content/api-docs/graphql/_index.md
index 255a936c3..ac013ef7e 100644
--- a/hugo/content/api-docs/graphql/_index.md
+++ b/hugo/content/api-docs/graphql/_index.md
@@ -56,9 +56,9 @@ Requires a service key on the tutorials XSUAA instance. Request one through the
## Prerequisite: Role Collection Assignment
-The `Tutorial.API` scope is only granted to identities assigned the **Tutorials API Consumer** role collection in your BTP subaccount. Ask a subaccount administrator to:
+The `Tutorial.API` scope is only granted to identities assigned the **Tutorials API Consumer** role collection in your SAP Business AI Platform subaccount. Ask a subaccount administrator to:
-1. Open the [BTP cockpit](https://cockpit.btp.cloud.sap) → your subaccount → **Security** → **Role Collections**.
+1. Open the [SAP Business AI Platform cockpit](https://cockpit.btp.cloud.sap) → your subaccount → **Security** → **Role Collections**.
2. Locate the `Tutorials API Consumer` role collection (auto-created by the tutorials MTA deploy).
3. Assign your identity (via **User** or **Identity Provider** tab).
diff --git a/hugo/content/cookies.md b/hugo/content/cookies.md
index 59c48063b..43ac938af 100644
--- a/hugo/content/cookies.md
+++ b/hugo/content/cookies.md
@@ -33,7 +33,7 @@ required cookies.
| Name | Purpose | Set by | Duration |
| --- | --- | --- | --- |
| Session cookie (XSUAA) | Keeps you signed in after you log in with your SAP account | SAP Application Router (XSUAA) | Session |
-| Routing cookie (`__VCAP_ID__`) | Routes your requests to the same backend instance for stable sessions | SAP BTP Cloud Foundry router | Session |
+| Routing cookie (`__VCAP_ID__`) | Routes your requests to the same backend instance for stable sessions | SAP Business AI Platform Cloud Foundry router | Session |
| SAP Identity Provider cookies | Set during the login flow when you authenticate with your SAP account | `accounts.sap.com` | Session / persistent — see the [SAP Trust Center privacy statement](https://www.sap.com/about/legal/privacy.html) |
### Functional {#functional}
diff --git a/hugo/content/operate/_index.md b/hugo/content/operate/_index.md
index d6673a715..647b1b04e 100644
--- a/hugo/content/operate/_index.md
+++ b/hugo/content/operate/_index.md
@@ -1,6 +1,6 @@
---
title: "Operate"
-description: "Run, deploy, secure, and govern what you build on BTP."
+description: "Run, deploy, secure, and govern what you build on SAP Business AI Platform."
type: "verb"
layout: "list"
verbKey: "OPERATE"
diff --git a/hugo/content/u9-glossary-demo.md b/hugo/content/u9-glossary-demo.md
index adc07e03a..b301e1995 100644
--- a/hugo/content/u9-glossary-demo.md
+++ b/hugo/content/u9-glossary-demo.md
@@ -18,9 +18,10 @@ body copy stays readable.
## What's covered
This site teaches developers how to build cloud-native business apps on SAP
-BTP. The default stack is CAP — the SAP Cloud Application Programming Model —
-which leans on CDS for data and service modeling. ABAP developers building
-on S/4HANA Cloud will instead reach for RAP, the ABAP-side equivalent.
+Business AI Platform. The default stack is CAP — the SAP Cloud Application
+Programming Model — which leans on CDS for data and service modeling. ABAP
+developers building on S/4HANA Cloud will instead reach for RAP, the ABAP-side
+equivalent.
## Deployment story
@@ -28,11 +29,12 @@ Production apps ship as an MTA (multitarget application) bundle pushed to
Cloud Foundry. The deployment binds an XSUAA instance for authentication, an
HDI container for HANA schema isolation, and optionally an IAS tenant when
the customer has a corporate IDP they want to federate against. The HANA
-database itself runs as a managed BTP service — no infrastructure to babysit.
+database itself runs as a managed SAP Business AI Platform service — no
+infrastructure to babysit.
## What you don't pay for
-Note how each acronym in this paragraph (BTP, CAP, CDS, RAP, MTA, XSUAA, HDI,
+Note how each acronym in this paragraph (CAP, CDS, RAP, MTA, XSUAA, HDI,
IAS, IDP, HANA) appears underlined only on its *first* mention above. The
second mention here renders as plain text — that's the "tag only the first
occurrence per page" policy.
diff --git a/hugo/hugo.toml b/hugo/hugo.toml
index 6a9e35408..b66c9eca7 100644
--- a/hugo/hugo.toml
+++ b/hugo/hugo.toml
@@ -11,7 +11,7 @@ enableRobotsTXT = false # we ship our own via the robots output format
# instead of the monolithic ui5-bootstrap.ts bundle. Flag OFF by default; flip to
# true once the split entries are validated. (#1777 Task 7)
ui5Split = false
- defaultDescription = 'Hands-on tutorials for SAP technologies — SAP BTP, ABAP Cloud, CAP, Fiori, HANA Cloud, and integration. Build, learn, and ship on SAP.'
+ defaultDescription = 'Hands-on tutorials for SAP technologies — SAP Business AI Platform, ABAP Cloud, CAP, Fiori, HANA Cloud, and integration. Build, learn, and ship on SAP.'
heroDescription = 'Step-by-step tutorials, multi-tutorial missions, and reference content for every SAP developer technology. Tested, maintained, and free to use.'
twitterHandle = '@sapdevs'
defaultOgImage = '/img/og-default.png'
diff --git a/hugo/layouts/_default/llms.txt b/hugo/layouts/_default/llms.txt
index 7f73eb912..50f253bc7 100644
--- a/hugo/layouts/_default/llms.txt
+++ b/hugo/layouts/_default/llms.txt
@@ -1,6 +1,6 @@
# SAP Developers Tutorials
-> Official tutorial platform for SAP technologies. Step-by-step tutorials, missions (multi-tutorial learning paths), and reference content for SAP BTP, ABAP Cloud, CAP, Fiori, HANA Cloud, and integration.
+> Official tutorial platform for SAP technologies. Step-by-step tutorials, missions (multi-tutorial learning paths), and reference content for SAP Business AI Platform, ABAP Cloud, CAP, Fiori, HANA Cloud, and integration.
Content policy: Citation in AI search and answer use cases is welcome. Use for model training is not.
See https://developers.sap.com/AGENTS.md.
diff --git a/hugo/layouts/_default/sitemapmd.md b/hugo/layouts/_default/sitemapmd.md
index 25138fd72..b45c3b28d 100644
--- a/hugo/layouts/_default/sitemapmd.md
+++ b/hugo/layouts/_default/sitemapmd.md
@@ -5,7 +5,7 @@
falls back to linking the /missions/ index, like llms.txt. */ -}}
# SAP Developers Tutorials — Site Map
-> Official tutorial platform for SAP technologies. Step-by-step tutorials, missions (multi-tutorial learning paths), and reference content for SAP BTP, ABAP Cloud, CAP, Fiori, HANA Cloud, and integration.
+> Official tutorial platform for SAP technologies. Step-by-step tutorials, missions (multi-tutorial learning paths), and reference content for SAP Business AI Platform, ABAP Cloud, CAP, Fiori, HANA Cloud, and integration.
Semantic site map mirroring the site's navigation, with missions expanded to their ordered tutorials.
diff --git a/hugo/layouts/_default/terms.html b/hugo/layouts/_default/terms.html
index eb6e39025..644886e1a 100644
--- a/hugo/layouts/_default/terms.html
+++ b/hugo/layouts/_default/terms.html
@@ -2,7 +2,7 @@
diff --git a/hugo/layouts/explore/about.html b/hugo/layouts/explore/about.html
index a9fac9312..312476407 100644
--- a/hugo/layouts/explore/about.html
+++ b/hugo/layouts/explore/about.html
@@ -51,7 +51,7 @@ How it's built
learning.sap.com ,
blog posts from the
SAP Community ,
- Discovery Center missions and BTP services from
+ Discovery Center missions and SAP Business AI Platform services from
discovery-center.cloud.sap ,
videos from the
SAP Developers YouTube channel
@@ -152,7 +152,7 @@ SAP Cloud Application Programming Model (CAP)
Service layer, scheduler, and the cron jobs that build the graph.
- SAP BTP Cloud Foundry
+ SAP Business AI Platform Cloud Foundry
The runtime.
diff --git a/hugo/layouts/partials/joule-starters.html b/hugo/layouts/partials/joule-starters.html
index 519ec8dfd..146542d62 100644
--- a/hugo/layouts/partials/joule-starters.html
+++ b/hugo/layouts/partials/joule-starters.html
@@ -18,14 +18,14 @@
],
"homepage": [
"I'm a long-time ABAP developer catching up on cloud — where do I start?",
- "Where can I find SAP BTP AI best practices?",
+ "Where can I find SAP Business AI Platform best practices?",
"Which SAP developer destination covers the Business Accelerator Hub APIs?",
"What's the difference between SAP Build, Build Code, and Build Apps?"
],
"tutorial-navigator": [
"Find me a CAP getting-started tutorial.",
"Show me ABAP Cloud tutorials for beginners.",
- "What missions cover SAP BTP?"
+ "What missions cover SAP Business AI Platform?"
],
"verb-learn": [
"I'm new to SAP cloud development — which learning journey should I start with?",
@@ -39,16 +39,16 @@
],
"verb-integrate": [
"Where can I browse SAP business APIs?",
- "Which BTP service handles eventing between SAP and non-SAP systems?",
- "How do I set up an API destination on BTP?"
+ "Which SAP Business AI Platform service handles eventing between SAP and non-SAP systems?",
+ "How do I set up an API destination on SAP Business AI Platform?"
],
"verb-operate": [
- "How do I log into the BTP Cockpit?",
- "Where is the BTP Discovery Center service catalog?",
- "Which CLI manages BTP subaccounts and entitlements?"
+ "How do I log into the SAP Business AI Platform cockpit?",
+ "Where is the SAP Business AI Platform Discovery Center service catalog?",
+ "Which CLI manages SAP Business AI Platform subaccounts and entitlements?"
],
"verb-ai": [
- "Where are the SAP BTP AI best practices?",
+ "Where are the SAP Business AI Platform best practices?",
"How do I build a Joule extension?",
"Show me the SAP AI Core documentation."
],
@@ -60,7 +60,7 @@
"search": [
"Find me a CAP getting-started tutorial.",
"Show me ABAP Cloud tutorials for beginners.",
- "What missions cover SAP BTP?"
+ "What missions cover SAP Business AI Platform?"
],
"mission": [
"Explain this mission's path.",
@@ -78,7 +78,7 @@
"Where is Thomas Jung based and what does he focus on?"
],
"generic": [
- "What's new in SAP BTP?",
+ "What's new in SAP Business AI Platform?",
"Help me find a tutorial.",
"Explain CAP in 30 seconds."
]
diff --git a/hugo/layouts/partials/verb-extras/btp-service-catalog.html b/hugo/layouts/partials/verb-extras/btp-service-catalog.html
index ccf14b06d..a8d7a52d1 100644
--- a/hugo/layouts/partials/verb-extras/btp-service-catalog.html
+++ b/hugo/layouts/partials/verb-extras/btp-service-catalog.html
@@ -3,8 +3,8 @@
Single hero link card to the SAP Discovery Center service catalog.
*/ -}}
@@ -685,6 +702,20 @@ const emptyStateMessage = computed(() => {
opacity: 0.9;
}
+/* Step 1 as a sign-in link while anonymous — inherits the hero's light text on
+ its dark background rather than a clashing default link color. */
+.instruction-text--login {
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ cursor: pointer;
+}
+.instruction-text--login:hover,
+.instruction-text--login:focus-visible {
+ opacity: 1;
+ text-decoration-thickness: 2px;
+}
+
/* ── Content Area ──────────────────────────────────────── */
.content-area {
diff --git a/hugo/layouts/partials/header.html b/hugo/layouts/partials/header.html
index d90f63674..b1f32cc6d 100644
--- a/hugo/layouts/partials/header.html
+++ b/hugo/layouts/partials/header.html
@@ -213,7 +213,10 @@
if (!isAuthenticated) {
try { localStorage.removeItem('autologin.optout'); } catch {}
try { sessionStorage.setItem('autologin.tried', '1'); } catch {}
- window.location.href = '/login?returnTo=' + encodeURIComponent(window.location.pathname + window.location.search);
+ // Deliberate sign-in: send to the /signin IdP chooser (not straight to
+ // /login) so the user picks an identity provider. maybeAutoLogin (silent
+ // returning-visitor SSO) still uses /login directly — see below.
+ window.location.href = '/signin?returnTo=' + encodeURIComponent(window.location.pathname + window.location.search);
return;
}
closeAllExcept(userPopover);
diff --git a/hugo/layouts/signin.html b/hugo/layouts/signin.html
index 484df46d2..55df13a8e 100644
--- a/hugo/layouts/signin.html
+++ b/hugo/layouts/signin.html
@@ -9,16 +9,29 @@
Sign in to SAP Developer
+
+
{{- /* ui5Split: illustration assets for , mirrors 403.html (#1777 Task 7) */}}
{{ if and (not site.Params.previewMode) site.Params.ui5Split }}{{ end }}
diff --git a/test/e2e/signin.spec.ts b/test/e2e/signin.spec.ts
index d19d11732..f4ed3b0ff 100644
--- a/test/e2e/signin.spec.ts
+++ b/test/e2e/signin.spec.ts
@@ -26,20 +26,29 @@ describe.skipIf(!hasBaseUrl())('e2e: /signin page (IAS social login, #2506)', ()
await browser?.close();
});
- it('renders both IdP-pinned sign-in buttons with correct hrefs', async () => {
+ it('renders the per-provider sign-in buttons with correct sap_idp hrefs', async () => {
const { context, page } = await newPage(browser, { authenticated: false });
try {
const response = await page.goto('/signin', { waitUntil: 'domcontentloaded' });
expect(response, 'no response received').not.toBeNull();
expect(response!.status(), 'unexpected status for /signin').toBe(200);
- const sapBtn = page.getByRole('link', { name: 'Sign in with SAP Account', exact: true });
- const uidBtn = page.getByRole('link', { name: "Sign In if you don't already have an SAP Account", exact: true });
-
- 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');
+ // Each button deep-links to one IAS corporate IdP via the approuter's
+ // dynamicIdentityProvider (sap_idp → &idp= on the XSUAA authorize
+ // URL → IAS routes straight to that provider). Google/X are IAS social
+ // tiles (not idp-addressable) and live behind the "More options" pass-through.
+ const buttons: Array<[string, string]> = [
+ ['Sign in with SAP Universal ID', '/login?sap_idp=sap.default'],
+ ['Sign in with GitHub', '/login?sap_idp=GitHub'],
+ ['Sign in with LinkedIn', '/login?sap_idp=LinkedIn'],
+ ['Sign in with Hugging Face', '/login?sap_idp=Hugging%20Face'],
+ ['More options (Google, X, & more)', '/login?sap_idp=sap.custom'],
+ ];
+ for (const [name, href] of buttons) {
+ const link = page.getByRole('link', { name, exact: true });
+ await expect(link, `button "${name}" not visible`).toBeVisible();
+ await expect(link, `button "${name}" href`).toHaveAttribute('href', href);
+ }
} finally {
await context.close();
}
From 3b90d49f457185b9e1199010d1b600dc43c05082 Mon Sep 17 00:00:00 2001
From: Thomas Jung
Date: Sat, 3 Oct 2026 18:59:18 -0400
Subject: [PATCH 43/46] =?UTF-8?q?feat(auth):=20RPT-style=20/signin=20?=
=?UTF-8?q?=E2=80=94=20provider=20logos=20+=20chain-form=20IdP=20routing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Redesign /signin to mirror the RPT Playground login (rpt.cloud.sap/login) within our Horizon theme: a centered card with eyebrow + 'Sign In' + subtitle, a short description, and a stacked column of logo buttons (logo left of 'Continue with ').
Providers: GitHub, Google, Hugging Face, LinkedIn, SAP ID. Apple omitted (requires a paid Apple Developer account). Logos sourced from the RPT login page into hugo/static/img/providers/ (self-hosted → same-origin, no CSP img-src change).
CRITICAL ROUTING FIX: corporate OIDC IdPs are NESTED inside IAS, so they need the approuter CHAIN form sap_idp=sap.custom, (XSUAA routes via the sap.custom IAS trust, then IAS applies idp=). The prior bare sap_idp= did nothing — XSUAA has no such origin, so every button silently fell through to the default IdP (the 'logs me in with default' bug). GitHub/LinkedIn/Hugging Face now use the chain form; SAP ID uses sap.default; Google routes to sap.custom (IAS page) until converted from a social tile to corporate OIDC.
Updates test/e2e/signin.spec.ts to the 5 Continue-with buttons + chain-form hrefs.
KNOWN FOLLOW-UP: an existing IAS SSO session can still be reused instead of honoring the chosen IdP (IAS prompt=login would force it, but the approuter does not forward prompt). Validate click-through on a clean browser.
---
hugo/layouts/signin.html | 159 ++++++++++++++++------
hugo/static/img/providers/Github.svg | 1 +
hugo/static/img/providers/Google.svg | 1 +
hugo/static/img/providers/SAP.svg | 11 ++
hugo/static/img/providers/huggingface.svg | 37 +++++
hugo/static/img/providers/linkedin.webp | Bin 0 -> 12560 bytes
test/e2e/signin.spec.ts | 10 +-
7 files changed, 170 insertions(+), 49 deletions(-)
create mode 100644 hugo/static/img/providers/Github.svg
create mode 100644 hugo/static/img/providers/Google.svg
create mode 100644 hugo/static/img/providers/SAP.svg
create mode 100644 hugo/static/img/providers/huggingface.svg
create mode 100644 hugo/static/img/providers/linkedin.webp
diff --git a/hugo/layouts/signin.html b/hugo/layouts/signin.html
index fbf49f4d0..b996bf640 100644
--- a/hugo/layouts/signin.html
+++ b/hugo/layouts/signin.html
@@ -1,37 +1,64 @@
{{ define "main" }}
-
-
- Sign in to SAP Developers
-
- Choose a provider to continue.
-
-
+
+
SAP® ID
+
Sign In
+
SAP Developers
+
+
+ Sign in to track your tutorial progress, earn completions, and personalize
+ your experience across SAP Developers.
+
+
+
+
+
Choose your authentication service:
+
-
-
- Sign in with SAP Universal ID
+ {{/*
+ Provider sign-in buttons, RPT-style (logo + label, full-width stacked).
+
+ Routing (approuter dynamicIdentityProvider → sap_idp):
+ - CORPORATE OIDC IdPs nested in IAS use the CHAIN form
+ sap_idp=sap.custom,: XSUAA routes via the sap.custom
+ IAS trust, then IAS applies idp= to pick that provider. A bare
+ sap_idp= does NOT work (XSUAA has no such origin → falls through
+ to default). Deep-linkable today: GitHub, LinkedIn, Hugging Face.
+ - SAP ID = direct XSUAA trust origin sap.default.
+ - Google is NOT yet a corporate OIDC IdP in IAS (it's a social tile, not
+ idp-addressable), so its button routes to sap.custom (the IAS page) where
+ Google appears. Convert it to corporate OIDC to make it a true deep-link.
+ Apple is intentionally omitted (requires a paid Apple Developer account).
+ NOTE: an existing IAS SSO session can be reused instead of honoring the
+ chosen IdP (IAS prompt=login would force it, but the approuter does not
+ forward prompt) — tracked as a follow-up.
+ */}}
+
+
+ Continue with GitHub
-
- Sign in with GitHub
+
+
+ Continue with Google
-
- Sign in with LinkedIn
+
+
+ Continue with Hugging Face
-
- Sign in with Hugging Face
+
+
+ Continue with LinkedIn
-
- More options (Google, X, & more)
+
+
+ Continue with SAP ID
-
- Whichever you choose, it's the same account — your progress and
- completions carry across providers.
+
+
+ By signing in, you accept our
+ Privacy Policy and
+ Cookie Statement .
@@ -51,41 +78,85 @@
Sign in to SAP Developer
-{{- /* ui5Split: illustration assets for , mirrors 403.html (#1777 Task 7) */}}
-{{ if and (not site.Params.previewMode) site.Params.ui5Split }}{{ end }}
{{ end }}
diff --git a/hugo/static/img/providers/Github.svg b/hugo/static/img/providers/Github.svg
new file mode 100644
index 000000000..37fa923df
--- /dev/null
+++ b/hugo/static/img/providers/Github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/hugo/static/img/providers/Google.svg b/hugo/static/img/providers/Google.svg
new file mode 100644
index 000000000..a3337bfde
--- /dev/null
+++ b/hugo/static/img/providers/Google.svg
@@ -0,0 +1 @@
+
diff --git a/hugo/static/img/providers/SAP.svg b/hugo/static/img/providers/SAP.svg
new file mode 100644
index 000000000..368f65410
--- /dev/null
+++ b/hugo/static/img/providers/SAP.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/hugo/static/img/providers/huggingface.svg b/hugo/static/img/providers/huggingface.svg
new file mode 100644
index 000000000..43c5d3c0c
--- /dev/null
+++ b/hugo/static/img/providers/huggingface.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hugo/static/img/providers/linkedin.webp b/hugo/static/img/providers/linkedin.webp
new file mode 100644
index 0000000000000000000000000000000000000000..2fb9f0ddc2ace9b4017a4f59a6b92458a7a0b19f
GIT binary patch
literal 12560
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
literal 0
HcmV?d00001
diff --git a/test/e2e/signin.spec.ts b/test/e2e/signin.spec.ts
index f4ed3b0ff..53e7874d6 100644
--- a/test/e2e/signin.spec.ts
+++ b/test/e2e/signin.spec.ts
@@ -38,11 +38,11 @@ describe.skipIf(!hasBaseUrl())('e2e: /signin page (IAS social login, #2506)', ()
// URL → IAS routes straight to that provider). Google/X are IAS social
// tiles (not idp-addressable) and live behind the "More options" pass-through.
const buttons: Array<[string, string]> = [
- ['Sign in with SAP Universal ID', '/login?sap_idp=sap.default'],
- ['Sign in with GitHub', '/login?sap_idp=GitHub'],
- ['Sign in with LinkedIn', '/login?sap_idp=LinkedIn'],
- ['Sign in with Hugging Face', '/login?sap_idp=Hugging%20Face'],
- ['More options (Google, X, & more)', '/login?sap_idp=sap.custom'],
+ ['Continue with GitHub', '/login?sap_idp=sap.custom,GitHub'],
+ ['Continue with Google', '/login?sap_idp=sap.custom'],
+ ['Continue with Hugging Face', '/login?sap_idp=sap.custom,Hugging%20Face'],
+ ['Continue with LinkedIn', '/login?sap_idp=sap.custom,LinkedIn'],
+ ['Continue with SAP ID', '/login?sap_idp=sap.default'],
];
for (const [name, href] of buttons) {
const link = page.getByRole('link', { name, exact: true });
From 508a8679727dc76d51e56524a3281b925121080a Mon Sep 17 00:00:00 2001
From: Thomas Jung
Date: Sat, 3 Oct 2026 19:12:56 -0400
Subject: [PATCH 44/46] feat(auth): Google deep-link on /signin (now corporate
OIDC)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Google was converted from an IAS social tile to a corporate 'OpenID Connect Compliant' IdP (discovery accounts.google.com, Client ID reused from the social tile, scopes openid profile email, callback registered in the Google OAuth app). Validated 3/3 on IAS; claims confirmed: real verified email + given_name/family_name/name + numeric sub (no preferred_username, so the GitHub-only resolver path is not triggered — Google resolves via the existing email tier, no Phase-2 change needed).
Flips the /signin Google button to the chain form sap_idp=sap.custom,Google so it deep-links to Google instead of the IAS page. Updates the e2e assertion + layout comment.
---
hugo/layouts/signin.html | 6 ++----
test/e2e/signin.spec.ts | 2 +-
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/hugo/layouts/signin.html b/hugo/layouts/signin.html
index b996bf640..e202c709d 100644
--- a/hugo/layouts/signin.html
+++ b/hugo/layouts/signin.html
@@ -25,9 +25,7 @@ Sign In
sap_idp= does NOT work (XSUAA has no such origin → falls through
to default). Deep-linkable today: GitHub, LinkedIn, Hugging Face.
- SAP ID = direct XSUAA trust origin sap.default.
- - Google is NOT yet a corporate OIDC IdP in IAS (it's a social tile, not
- idp-addressable), so its button routes to sap.custom (the IAS page) where
- Google appears. Convert it to corporate OIDC to make it a true deep-link.
+ - Deep-linkable corporate OIDC IdPs: GitHub, Google, Hugging Face, LinkedIn.
Apple is intentionally omitted (requires a paid Apple Developer account).
NOTE: an existing IAS SSO session can be reused instead of honoring the
chosen IdP (IAS prompt=login would force it, but the approuter does not
@@ -37,7 +35,7 @@ Sign In
Continue with GitHub
-
+
Continue with Google
diff --git a/test/e2e/signin.spec.ts b/test/e2e/signin.spec.ts
index 53e7874d6..8730e115d 100644
--- a/test/e2e/signin.spec.ts
+++ b/test/e2e/signin.spec.ts
@@ -39,7 +39,7 @@ describe.skipIf(!hasBaseUrl())('e2e: /signin page (IAS social login, #2506)', ()
// tiles (not idp-addressable) and live behind the "More options" pass-through.
const buttons: Array<[string, string]> = [
['Continue with GitHub', '/login?sap_idp=sap.custom,GitHub'],
- ['Continue with Google', '/login?sap_idp=sap.custom'],
+ ['Continue with Google', '/login?sap_idp=sap.custom,Google'],
['Continue with Hugging Face', '/login?sap_idp=sap.custom,Hugging%20Face'],
['Continue with LinkedIn', '/login?sap_idp=sap.custom,LinkedIn'],
['Continue with SAP ID', '/login?sap_idp=sap.default'],
From 9abb1df57bf80e867f14552e2547732f334ba815 Mon Sep 17 00:00:00 2001
From: Thomas Jung
Date: Sat, 3 Oct 2026 19:54:48 -0400
Subject: [PATCH 45/46] chore(deploy): enable GITHUB_OIDC_ENABLED on the PROD
approuter
Adds GITHUB_OIDC_ENABLED: "true" to the tutorials-approuter properties: block in deploy/prod.mtaext (mirrors dev.mtaext). Without it the GitHub-OIDC shim is an inert no-op in PROD. properties: not env: (MTA drops module-level env). Final code change needed before the PROD GitHub-login rollout; shim secrets live in the PROD credstore. The /signin entry-point wiring is already in place (header.html routes deliberate sign-in to /signin).
---
deploy/prod.mtaext | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/deploy/prod.mtaext b/deploy/prod.mtaext
index f486d085c..b5999b025 100644
--- a/deploy/prod.mtaext
+++ b/deploy/prod.mtaext
@@ -65,6 +65,15 @@ modules:
# `openid` (IAS JWTs carry no scopes), vs the XSUAA `.Everyone`
# form. MUST be 'ias' because XSUAA_MCP_URL points at an IAS tenant.
MCP_ISSUER_KIND: ias
+ # GitHub-OIDC shim toggle (approuter/lib/github-oidc-shim.js) — mirrors
+ # dev.mtaext. Read via process.env at call time; off ⇒ inert no-op. MUST be
+ # a `properties:` entry, NOT `env:` (MTA silently drops module-level env).
+ # The shim is host-relative, so the PROD approuter serves a valid shim at
+ # the prod host; the shared-tenant IAS GitHub IdP's Discovery URL is pointed
+ # at this prod host (see the PROD topology decision). Secrets (GITHUB_CLIENT_ID
+ # /SECRET, GITHUB_OIDC_SIGNING_KEY, GITHUB_OIDC_STATE_SECRET, GITHUB_OIDC_IAS_
+ # CLIENT_ID/SECRET) live in the PROD BTP Credential Store, never here.
+ GITHUB_OIDC_ENABLED: "true"
parameters:
app-name: tutorials-prod-approuter
instances: 2
From 9a512ff51c27f0fa1774421300b9564a8261d39e Mon Sep 17 00:00:00 2001
From: Thomas Jung
Date: Sat, 3 Oct 2026 20:07:39 -0400
Subject: [PATCH 46/46] chore(release): bump deploy version to 1.34.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Minor bump for the GitHub-login + RPT-style /signin feature release (new sign-in page, GitHub/Google/Hugging Face/LinkedIn corporate-OIDC deep-link buttons, GitHub-OIDC shim, CAP identity linking). Precedes the DEV->main release promotion + PROD blue-green deploy. The user-facing changelog (hugo/data/whats_new.json) is generated from merged PRs by build:whats-new-snapshot — no hand edit.
---
.deploy/mta.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml
index 778979a4e..704a2d421 100644
--- a/.deploy/mta.yaml
+++ b/.deploy/mta.yaml
@@ -10,7 +10,7 @@ ID: tutorials-ims
# Bump this on each release you deploy — it's the version shown by `cf mtas`
# and in the mtar filename (tutorials-ims_.mtar). Deploy is manual:
# `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`.
-version: 1.33.0
+version: 1.34.0
# Top-level parameters (overridable per-env via deploy/.mtaext).
parameters: