From 3f44a68554aa16d6730ec74ebb0952c609c9ea3f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sat, 3 Oct 2026 21:56:27 -0400 Subject: [PATCH 01/37] docs: follow-up for /signin IdP re-selection over active IAS SSO session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the known SSO-reuse limitation (shipped 1.34.0): clicking a provider button on /signin while an IAS session exists reuses that session instead of honoring the chosen IdP. Root cause: approuter v23 cannot send OIDC prompt=login (closed authorize-URL query set; hand-rolling breaks the state/PKCE callback). Fix is IAS-side: a Conditional Authentication rule forcing re-auth when a corporate idp is requested (preferred), or a dedicated force-auth IAS app (fallback) — scoped so the seamless returning-visitor path stays silent. Needs IAS admin. --- .../operations/signin-sso-reauth-followup.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/developers/operations/signin-sso-reauth-followup.md diff --git a/docs/developers/operations/signin-sso-reauth-followup.md b/docs/developers/operations/signin-sso-reauth-followup.md new file mode 100644 index 000000000..0d09ee94f --- /dev/null +++ b/docs/developers/operations/signin-sso-reauth-followup.md @@ -0,0 +1,59 @@ +# Follow-up: force IdP re-selection on /signin over an existing IAS SSO session + +**Status:** known limitation, shipped 1.34.0 (GitHub-login + RPT `/signin`). Not a regression — login works correctly for logged-out/fresh users. This only affects **switching providers mid-session**. + +## Symptom +On `/signin`, clicking a provider button (e.g. LinkedIn) when the user already has an +active IAS SSO session silently logs them in with the **existing** session's IdP instead +of the chosen one. The `sap_idp=sap.custom,` chain correctly selects the IdP only +when there is no session to reuse. + +## Root cause (investigated 2026-10-03) +Fixing this requires OIDC `prompt=login` (force re-authentication) on the IAS +`/oauth2/authorize` request. **The approuter cannot send it:** + +- `@sap/approuter` v23.3.0 builds the authorize URL in + `lib/passport/oauth2.js:getCodeAuthorizationUrl` (lines 27-70) from a **closed** query set + (`response_type`, `client_id`, `redirect_uri`, `idp`, `scope`, `state`, `code_challenge`). + There is **no** `prompt`/`max_age` field and **no** passthrough for arbitrary inbound + params. Only `sap_idp` is consumed (-> `idp`). Confirmed: zero `prompt`/`max_age` matches + in the whole package. +- XSUAA (`xs-security.json`) has no per-request force-reauth / `prompt` config. +- **Hand-rolling the authorize URL in an approuter middleware is NOT safe here**: this app + runs with state protection AND PKCE both ON by default (`env-config.js:243` + `:269-270`, + neither overridden). The approuter's `/login/callback` strictly validates a server-side + cached `state` + PKCE `code_verifier` (`oauth2-strategy.js:118-145`, `:249-260`) that a + hand-rolled redirect can't populate -> the callback would 401. It would only work by + disabling those protections app-wide — an unacceptable security downgrade. + +## The fix (IAS-side, admin console — tenant atxgsg7zi) +Force re-auth **scoped to the deliberate corporate-IdP clicks**, without breaking the +seamless returning-visitor path (`header.html maybeAutoLogin`, which must stay silent). + +**Preferred — Conditional Authentication rule (scalpel):** add an IAS Conditional +Authentication rule that forces re-authentication only when a specific corporate `idp` +(GitHub / Google / Hugging Face / LinkedIn) is requested, leaving the default/SAP-ID path +seamless. (SAP KBA 3195136 conditional auth; combinable with forced auth.) + +**Fallback — dedicated IAS application:** point the provider-button flow at a separate IAS +OIDC application that has "Force user authentication for every application access" ON +(SAP KBA 3487096), keeping the existing app (used by `maybeAutoLogin`) with it OFF. More +moving parts; the only repo change would be pointing those `/login?sap_idp=...` buttons at +the route/client bound to that app. + +**Load-bearing caveat:** the plain per-application "force authentication" toggle is +all-or-nothing for that app — without the Conditional-Auth rule OR a separate app it would +also force re-auth on the silent returning-visitor flow, breaking "seamless". The scoping +is the real work, not flipping the toggle. + +## Scope / effort +- IAS-admin-console change on tenant `atxgsg7zi`; **no approuter/CAP code change** required + for the Conditional-Auth path (the `sap_idp` chain already selects the IdP). +- Needs the IAS administrator. Verify the Conditional-Auth rule can key on the requested + `idp` before committing to the scalpel vs. the separate-app fallback. + +## Current wiring (reference) +- Buttons: `hugo/layouts/signin.html` — `` + (SAP ID = `sap.default`). +- Seamless auto-login (must stay silent): `hugo/layouts/partials/header.html` + `maybeAutoLogin()` -> `/login?returnTo=...`; deliberate profile-click -> `/signin`. From 0a0b473e866420e8605a71d1f65775a16be76efd Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 08:24:07 -0400 Subject: [PATCH 02/37] docs(auth): document shipped 5-provider social login + IAS admin setup Reconcile both auth docs with the shipped per-provider sign-in (superseding the original two-button design): - ias-setup.md Step 8: rewrite as a per-provider IAS admin-console runbook. GitHub via the in-approuter OIDC shim (Corporate IdP -> OpenID Connect Compliant pointed at /github-oidc/.well-known/openid-configuration, five credstore secrets, GitHub OAuth app callback + scopes); Google/LinkedIn/ Hugging Face as native OIDC Corporate IdPs. Documents the load-bearing trust Display Names, redirect/callback URIs, and the chain-form sap_idp=sap.custom, routing requirement. - authentication.md: replace the stale 'Two-Button Sign-In' section (which named Microsoft and a single sap.custom button) with the live 5-button picker, direct vs chain sap_idp forms, a GitHub-OIDC shim subsection, and a corrected identity-merge boundary (GitHub subject = numeric id, email advisory). --- .../developers/architecture/authentication.md | 67 ++++++---- docs/developers/operations/ias-setup.md | 115 ++++++++++++++---- 2 files changed, 139 insertions(+), 43 deletions(-) diff --git a/docs/developers/architecture/authentication.md b/docs/developers/architecture/authentication.md index 4adb06f1f..bd385e995 100644 --- a/docs/developers/architecture/authentication.md +++ b/docs/developers/architecture/authentication.md @@ -418,42 +418,65 @@ Switch to a custom IAS tenant if you later need: --- -### Two-Button Sign-In and IdP Pinning +### Social Sign-In Page and IdP Pinning -The platform provides a themed public sign-in page (`/signin`) that allows users to choose between two authentication flows, each routed to a different Identity Provider. +The platform provides a themed public sign-in page (`/signin`) that presents a **per-provider picker** — one button per identity provider — and pins the chosen IdP so the user never sees XSUAA's raw IdP-chooser screen. The admin-console setup for each provider is documented in [ias-setup.md § Step 8](../operations/ias-setup.md#step-8-social--corporate-identity-providers-the-live-production-setup). #### Sign-In Page (`/signin`) -The `/signin` page is a public, unauthenticated Hugo page (served by AppRouter with `authenticationType: "none"`). It presents two buttons: +The `/signin` page is a public, unauthenticated Hugo page (`hugo/layouts/signin.html`, served by AppRouter with `authenticationType: "none"`). It presents **five** provider buttons (RPT-style: logo + label, full-width stacked): -1. **"Sign in with SAP Account"** — Routes to `/login?sap_idp=sap.default` (SAP ID Service, the default universal IDP) -2. **"Sign In if you don't already have an SAP Account"** — Routes to `/login?sap_idp=sap.custom` (IAS tenant `atxgsg7zi`, which federates social providers like GitHub, Google, Microsoft) +| Button | Routes to | Backing IdP | +|--------|-----------|-------------| +| **Continue with GitHub** | `/login?sap_idp=sap.custom,GitHub` | IAS Corporate IdP `GitHub`, backed by the in-approuter [GitHub-OIDC shim](#github-oidc-shim) | +| **Continue with Google** | `/login?sap_idp=sap.custom,Google` | IAS Corporate IdP `Google` (OIDC-native) | +| **Continue with Hugging Face** | `/login?sap_idp=sap.custom,Hugging%20Face` | IAS Corporate IdP `Hugging Face` (OIDC-native) | +| **Continue with LinkedIn** | `/login?sap_idp=sap.custom,LinkedIn` | IAS Corporate IdP `LinkedIn` (OIDC-native) | +| **Continue with SAP ID** | `/login?sap_idp=sap.default` | SAP ID Service (direct XSUAA trust) | -The `/signin` chooser is reached when an anonymous visitor deliberately signs in (the shellbar profile action redirects to `/signin?returnTo=`); the chooser forwards that `returnTo` onto the chosen `/login?sap_idp=…` URL so the user lands back where they started. The silent returning-visitor auto-login (`maybeAutoLogin`, issue #1689) still targets `/login` directly — it resolves transparently against an existing IdP SSO session and must not surface the chooser. +> **No Microsoft, no Apple.** Microsoft is not configured. Apple is intentionally omitted — "Sign in with Apple" requires a paid Apple Developer account. The live button set is exactly the five above; the authoritative source is `hugo/layouts/signin.html`. -Both flows eventually reach the same app via XSUAA, which re-issues its own OAuth token. The user sees XSUAA's token in the session, not the upstream IdP's token. +The `/signin` page is reached when an anonymous visitor deliberately signs in (the shellbar profile action redirects to `/signin?returnTo=`); a same-origin-guarded inline script forwards that `returnTo` onto the chosen `/login?sap_idp=…` URL so the user lands back where they started. The silent returning-visitor auto-login (`maybeAutoLogin`, issue #1689) still targets `/login` directly — it resolves transparently against an existing IdP SSO session and must not surface the page. + +Every flow reaches the same app via XSUAA, which re-issues its own OAuth token. The user sees XSUAA's token in the session, not the upstream IdP's token. #### IdP Pinning via `sap_idp` Query Parameter -The `/login` route in `xs-app.json` is configured with `dynamicIdentityProvider: true`. This enables the `sap_idp` query parameter: +The `/login` route in `xs-app.json` is configured with `dynamicIdentityProvider: true`. This enables the `sap_idp` query parameter, which comes in two forms: ``` -/login?sap_idp=sap.default → AppRouter sets login_hint={"origin":"sap.default"} -/login?sap_idp=sap.custom → AppRouter sets login_hint={"origin":"sap.custom"} +/login?sap_idp=sap.default → login_hint={"origin":"sap.default"} (direct XSUAA trust) +/login?sap_idp=sap.custom, → login_hint={"origin":"sap.custom"} + IAS idp= ``` -The AppRouter translates the `sap_idp` parameter into an XSUAA `login_hint` claim. XSUAA receives this hint and bypasses the IdP chooser screen, jumping directly to the specified IdP's authentication flow. This provides a seamless two-button experience — the user never sees an "Account Chooser" or "Which IdP?" dialog. +- **Direct form** (`sap.default`) pins an XSUAA trust origin directly — SAP ID Service. +- **Chain form** (`sap.custom,`) is required for every corporate OIDC IdP nested inside IAS. XSUAA routes to the `sap.custom` IAS trust origin, then IAS applies `idp=` to select the specific provider. **A bare `sap_idp=GitHub` does NOT work** — XSUAA has no such origin, so it falls through to the default IdP. The `` must exactly match the Corporate IdP **Display Name** configured in IAS (`GitHub`, `Google`, `Hugging Face`, `LinkedIn`). + +The AppRouter translates `sap_idp` into an XSUAA `login_hint` claim; XSUAA bypasses the chooser and jumps straight to the specified IdP. The user never sees an "Account Chooser" dialog. + +> **Caveat — IdP switch needs an existing-session bust.** An existing IAS SSO session can be reused instead of honoring the freshly-chosen IdP (IAS `prompt=login` would force a re-auth, but the approuter does not forward `prompt`). Switching providers reliably requires `/logout` first — see [Session and IdP Switching](#session-and-idp-switching). Tracked as a follow-up. + +#### GitHub-OIDC Shim + +GitHub is **OAuth 2.0 only** — no OIDC discovery document, no `id_token`, no JWKS — so IAS cannot federate it as an OpenID Connect Corporate IdP directly. A small OIDC-compliant shim runs **inside the approuter** (`approuter/lib/github-oidc-shim.js`, signing keys in `approuter/lib/github-oidc-keys.js`), wrapping GitHub's OAuth2 so IAS sees a standard OIDC provider. + +- **Mount:** `/github-oidc/*` on the approuter — `/.well-known/openid-configuration`, `/authorize`, `/callback`, `/token`, `/jwks`, `/userinfo`. +- **IAS registration:** a Corporate IdP of type **OpenID Connect Compliant** pointed at `https:///github-oidc/.well-known/openid-configuration`. +- **Flag:** gated by `GITHUB_OIDC_ENABLED` (approuter env, read at call time). Currently `"true"` on DEV and PROD. Off ⇒ the GitHub button falls through to the default IdP. +- **Tokens:** stateless (the authorize→token correlation is an encrypted A256GCM JWT used as the `code`), RS256 `id_token`s, PKCE S256 required. +- **Secrets:** all in BTP Credential Store, never in `.mtaext` or source — see [ias-setup.md § Step 8c](../operations/ias-setup.md#step-8c--github-needs-the-github-oidc-shim) for the five aliases and the full GitHub + IAS admin steps. +- **Identity key:** GitHub's **numeric user id** is the OIDC `sub`; the GitHub handle is carried separately as `githubLogin`. Email is advisory only and never keys identity (see [known boundary](#known-boundary-social-login-without-sap-id-account)). #### Identity Resolution Across IdPs -Both IdPs federate through a single XSUAA instance. When a user authenticates via either flow: +All IdPs federate through a single XSUAA instance. When a user authenticates via any flow: -1. **Authentication** happens at the upstream IdP (SAP ID Service or IAS) +1. **Authentication** happens at the upstream IdP (SAP ID Service directly, or a corporate/social provider via IAS) 2. **Token re-issuance** happens at XSUAA — a new JWT is issued to the app with: - - `user_uuid` claim = stable unique identifier (either from SAP ID or IAS) - - `email` claim = user's email address (if provided by the IdP) + - `user_uuid` claim = stable unique identifier (from SAP ID or IAS) + - `email` claim = user's email address (if provided by the IdP; advisory for social providers) - `given_name`, `family_name` = user profile fields -3. **Identity merge** (via the `(iss,sub)` resolver, #2552): If a user logs in via one IdP and later logs in via the other with the same email address, the system detects the email match and merges both logins to the same `user_uuid` in the database. This allows a user to seamlessly switch between "Sign in with SAP" and "Sign in with SAP Universal ID" without creating duplicate accounts. +3. **Identity merge** (via the `(iss,sub)` resolver in `packages/core/resolve-db-user.js`, #2552): if the same human logs in via different providers with the same email, the resolver's email tier merges both logins to the same `user_uuid`. This lets a user switch between SAP ID and a social provider without creating duplicate accounts — subject to the [known boundary](#known-boundary-social-login-without-sap-id-account) below. #### Session and IdP Switching @@ -467,14 +490,14 @@ Both IdPs federate through a single XSUAA instance. When a user authenticates vi #### Known Boundary: Social Login Without SAP ID Account -When a user logs in via a social provider (GitHub, Google, etc. via IAS) **and the email address does not exist in any prior SAP ID login**: +When a user logs in via a social provider (GitHub, Google, Hugging Face, LinkedIn via IAS) **and no matching email exists on a prior SAP ID login**: -- A new `Users` database row is created with `sapId = null`, linked to the social identity via a `UserIdentities` (issuer, subject) row -- Later, if the same user logs in via SAP ID Service with the same email but a different identity key -- The system detects the email collision but cannot automatically merge because they have different identity keys stored in `UserIdentities` (issue #2552) -- **Workaround:** The user can manually merge accounts via the `/admin-ui/#account-merge` endpoint if they have `Admin` scope, or the DeveloperService can expose a user-facing merge endpoint in a future update +- A new `Users` database row is created with `sapId = null`, keyed by the provider's `(issuer, subject)`. For GitHub specifically the subject is the **numeric GitHub user id** (never the email, which is advisory and often null); the GitHub handle is stored as `githubLogin`. +- Later, if the same human logs in via SAP ID Service with the same email but a different identity key, the resolver detects the email collision but **cannot auto-merge** because the two logins carry different `(iss, sub)` keys (issue #2552). +- **Why email can't rescue this:** `Users.email` is NULL on the large majority of PROD rows and backfills lazily on login, so a social user whose SAP-ID row has no email yet will miss the email-match tier and get a separate `user_uuid`. +- **Workaround:** an explicit account merge via `/admin-ui/#account-merge` (requires `Admin` scope), or a future user-facing merge endpoint. -This edge case only occurs for users who first log in socially without a pre-existing SAP ID. Most SAP developers have an existing SAP ID, so the happy path (same email, automatic merge) covers the common case. +This edge case only affects users who sign in socially *before* ever having a resolvable SAP ID email on file. Most SAP developers have an existing SAP ID with a known email, so the happy path (same email → automatic merge) covers the common case. --- diff --git a/docs/developers/operations/ias-setup.md b/docs/developers/operations/ias-setup.md index 490f33d20..b39d27970 100644 --- a/docs/developers/operations/ias-setup.md +++ b/docs/developers/operations/ias-setup.md @@ -167,29 +167,102 @@ npx cds bind --exec -- node -e " " ``` -### Step 8: Social Identity Providers (Optional) +### Step 8: Social / Corporate Identity Providers (the live production setup) + +> **This is the configuration that ships today** (DEV + PROD, `GITHUB_OIDC_ENABLED: "true"` in `deploy/dev.mtaext` / `deploy/prod.mtaext`). The themed `/signin` page (`hugo/layouts/signin.html`) presents five sign-in buttons: +> +> | Button | `sap_idp` routing | IAS registration | +> |--------|-------------------|------------------| +> | **Continue with SAP ID** | `sap.default` | None — direct XSUAA trust to SAP ID Service | +> | **Continue with GitHub** | `sap.custom,GitHub` | OIDC-Compliant Corporate IdP, backed by **our GitHub-OIDC shim** | +> | **Continue with Google** | `sap.custom,Google` | OIDC-Compliant Corporate IdP (Google is OIDC-native) | +> | **Continue with LinkedIn** | `sap.custom,LinkedIn` | OIDC-Compliant Corporate IdP (LinkedIn is OIDC-native) | +> | **Continue with Hugging Face** | `sap.custom,Hugging Face` | OIDC-Compliant Corporate IdP (Hugging Face is OIDC-native) | +> +> **Not "Social IdPs" in IAS.** Despite the user-facing framing, none of these are configured under IAS's **Social Identity Providers** screen. All four non-SAP providers are registered as **Corporate Identity Providers → OpenID Connect Compliant**. This is deliberate: the chain-form `sap_idp=sap.custom,` deep-link (see [authentication.md](../architecture/authentication.md#idp-pinning-via-sap_idp)) routes XSUAA through the `sap.custom` IAS trust and then has IAS apply `idp=` to pick the specific provider — a mechanism that only works for Corporate IdPs that carry a stable trust **Name**. +> +> **The IdP trust Name is load-bearing.** The `` in each button's `sap_idp=sap.custom,` **must exactly match** the Corporate Identity Provider **Display Name** you set in IAS (`GitHub`, `Google`, `LinkedIn`, `Hugging Face`). Rename a provider in IAS and the matching `/signin` button breaks (falls through to the default IdP). If you change a Name, update `hugo/layouts/signin.html` in lockstep. + +#### Step 8a — Google (OIDC-native, simplest) + +1. **Google Cloud Console** → create OAuth 2.0 credentials (Web application) + - Authorized redirect URI: `https://.accounts.ondemand.com/oauth2/callback` +2. **IAS Admin Console** → **Identity Providers** → **Corporate Identity Providers** → **Create** + - **Display Name**: `Google` *(must match the `/signin` button's `sap_idp=sap.custom,Google`)* + - **Type**: **OpenID Connect Compliant** +3. **OpenID Connect Configuration**: + - **Discovery URL**: `https://accounts.google.com/.well-known/openid-configuration` + - **Client ID** / **Client Secret**: from step 1 + - **Scopes**: `openid profile email` +4. **Identity Federation** → "Use Identity Authentication user store" (email-based matching, so social users merge onto their existing SAP-ID row by email — see [identity continuity](#step-7-verify-identity-continuity)). +5. Save, then **Enable** the IdP for the tutorials application under **Applications & Resources → Applications → → Identity Provider**. + +#### Step 8b — LinkedIn and Hugging Face (OIDC-native, same recipe as Google) + +Both expose a standard OIDC discovery document, so they follow the Google recipe exactly — only the provider-side app registration and discovery URL differ: + +| Provider | Register app at | Discovery URL | IAS Display Name | +|----------|-----------------|---------------|------------------| +| LinkedIn | [LinkedIn Developers](https://www.linkedin.com/developers/apps) → "Sign In with LinkedIn using OpenID Connect" product | `https://www.linkedin.com/oauth/.well-known/openid-configuration` | `LinkedIn` | +| Hugging Face | [HF OAuth app settings](https://huggingface.co/settings/applications) | `https://huggingface.co/.well-known/openid-configuration` | `Hugging Face` | + +For each: redirect URI = `https://.accounts.ondemand.com/oauth2/callback`, scopes `openid profile email`, Type = **OpenID Connect Compliant**, and the **Display Name must match the `/signin` button Name exactly** (note the space in `Hugging Face`). + +> **Apple is intentionally omitted** — "Sign in with Apple" requires a paid Apple Developer account. If you add it later, register it as another OIDC Corporate IdP and add a matching button to `signin.html`. + +#### Step 8c — GitHub (needs the GitHub-OIDC shim) + +GitHub is **OAuth 2.0 only** — it has no OIDC discovery document, no `id_token`, and no JWKS endpoint, so IAS cannot federate it as an OpenID Connect Corporate IdP directly. We bridge the gap with a small OIDC-compliant shim that runs **in the approuter** (`approuter/lib/github-oidc-shim.js`), wrapping GitHub's OAuth2 so IAS sees a normal OIDC provider. + +The shim mounts at `/github-oidc/*` on the approuter and exposes the full OIDC surface IAS needs: + +| Endpoint | Purpose | +|----------|---------| +| `GET /github-oidc/.well-known/openid-configuration` | OIDC discovery document | +| `GET /github-oidc/authorize` | Validates the IAS request, 302 → GitHub | +| `GET /github-oidc/callback` | Exchanges the GitHub code, fetches the profile, 302 → IAS | +| `POST /github-oidc/token` | Verifies PKCE, mints the RS256 `id_token` | +| `GET /github-oidc/jwks` | Public key for IAS to verify the `id_token` | + +**Flag:** the shim is gated by `GITHUB_OIDC_ENABLED` (approuter env, read at call time). Currently `"true"` on both DEV and PROD. Off ⇒ the handler is a no-op pass-through (GitHub button then falls through to the default IdP). + +**Prerequisites — five credstore secrets** (BTP Credential Store, resolved via `approuter/lib/credstore-secret.js`; never in `.mtaext` or source). Rotate via `/admin-ui/#secrets`: + +| Alias | What it is | +|-------|-----------| +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | The GitHub **OAuth app** credentials (the shim is GitHub's client) | +| `GITHUB_OIDC_IAS_CLIENT_ID` / `GITHUB_OIDC_IAS_CLIENT_SECRET` | The client credentials **IAS** uses to call the shim's `/token` back-channel (IAS is the shim's client) | +| `GITHUB_OIDC_SIGNING_KEY` | RSA private key (PKCS#8 PEM) the shim signs `id_token`s with; the public JWK is derived from it on demand | +| `GITHUB_OIDC_STATE_SECRET` | Any sufficiently-random string; seals the stateless authorize→token correlation (A256GCM) | + +**GitHub side** — create an OAuth app at [GitHub Developer Settings](https://github.com/settings/developers): +- **Authorization callback URL**: `https:///github-oidc/callback` +- Copy the Client ID/Secret into the `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` credstore aliases. + +**IAS admin steps** — register the shim as a Corporate IdP: + +1. **IAS Admin Console** → **Identity Providers** → **Corporate Identity Providers** → **Create** + - **Display Name**: `GitHub` *(must match `sap_idp=sap.custom,GitHub`)* + - **Type**: **OpenID Connect Compliant** +2. **OpenID Connect Configuration**: + - **Discovery URL**: `https:///github-oidc/.well-known/openid-configuration` — IAS reads the shim's discovery, authorize, token, and JWKS endpoints from here automatically. + - **Client ID**: the value you put in `GITHUB_OIDC_IAS_CLIENT_ID` + - **Client Secret**: the value you put in `GITHUB_OIDC_IAS_CLIENT_SECRET` + - **Scopes**: `openid profile email` + - **PKCE**: S256 (the shim requires it) +3. **Identity Federation** → "Use Identity Authentication user store" (email-based matching). **Caveat:** GitHub emails are advisory — the shim keys identity on the **GitHub numeric user id** (OIDC `sub`), never on email, because `/user.email` is frequently null and noreply synthetics are not unique. A GitHub user whose verified primary email is private, or whose SAP-ID row has no email yet, will land on a **separate** `Users` row until an explicit account merge (the known #2552 boundary — see [authentication.md](../architecture/authentication.md#known-boundary-social-login-without-sap-id-account)). +4. Save, then **Enable** `GitHub` for the tutorials application (same place as Step 8a #5). + +**Verify the shim is reachable before registering in IAS:** -With IAS as trust point, add social logins: - -#### Google - -1. Create OAuth credentials at [Google Cloud Console](https://console.cloud.google.com) - - Redirect URI: `https://.accounts.ondemand.com/ui/oauth/googleCallback` -2. **IAS Admin Console** → **Identity Providers** → **Social Identity Providers** → **Google** -3. Enter Client ID and Client Secret -4. Set Status = Active - -#### GitHub +```bash +# Discovery doc should return JSON with issuer = https:// +curl -s https:///github-oidc/.well-known/openid-configuration | jq .issuer +# JWKS should return one RS256 key +curl -s https:///github-oidc/jwks | jq '.keys[0].kty, .keys[0].alg' +``` -1. Create OAuth app at [GitHub Developer Settings](https://github.com/settings/developers) - - Callback URL: `https://.accounts.ondemand.com/ui/oauth/customIdpCallback` -2. **IAS Admin Console** → **Social Identity Providers** → **Custom** -3. Configure manually: - - Authorization: `https://github.com/login/oauth/authorize` - - Token: `https://github.com/login/oauth/access_token` - - User Info: `https://api.github.com/user` - - Scopes: `read:user`, `user:email` -4. Set Status = Active +If either returns a 503 `server_error`, a credstore secret is missing (`GITHUB_OIDC_SIGNING_KEY` for jwks, `GITHUB_CLIENT_ID` for authorize). If they 404, `GITHUB_OIDC_ENABLED` is not `"true"` on the approuter. --- From 978e67012bad9384ae7b90dc1856d967c9009773 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 08:45:50 -0400 Subject: [PATCH 03/37] fix(mcp): stop anonymous search surfacing deleted/unpublished tutorials (#2631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both anonymous MCP tutorial-search surfaces returned soft-deleted or unpublished tutorials with /tutorials/ links that 404 (the Joule Desktop "broken link" bug). Soft-delete flips Tutorials.status to INACTIVE but keeps the row + embeddings; a slug can also drop out of a content publish without any status change. - semantic_search (srv/lib/semantic-search.js): gate the tutorials corpus on Tutorials.status (ACTIVE/null) in both the HANA cosine SQL and the SQLite JS-rank path — soft-deleted embeddings no longer rank. - Both tools now drop any hit whose slug has no servable content in ContentCurrent, via a shared, dual-dialect, fail-open helper (srv/lib/servable-slugs.js). ContentCurrent is the serve path's source of truth, so this closes the unpublished-without-status-flip case for search_tutorials too. Fail-open throughout: a content-presence check error keeps all slugs rather than blanking an anonymous client's results. --- srv/lib/semantic-search.js | 44 +++++++++++++------- srv/lib/servable-slugs.js | 69 +++++++++++++++++++++++++++++++ srv/search-service.js | 19 ++++++--- test/search-service.test.js | 35 ++++++++++++++++ test/unit/semantic-search.test.js | 38 +++++++++++++++-- test/unit/servable-slugs.test.js | 52 +++++++++++++++++++++++ 6 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 srv/lib/servable-slugs.js create mode 100644 test/unit/servable-slugs.test.js diff --git a/srv/lib/semantic-search.js b/srv/lib/semantic-search.js index 942efaa0e..218851522 100644 --- a/srv/lib/semantic-search.js +++ b/srv/lib/semantic-search.js @@ -32,6 +32,7 @@ import crypto from 'node:crypto'; import cds from '@sap/cds'; import { embed as defaultEmbed } from './embedding-client.js'; import { topConceptsByCosine } from './kg/concept-embedding-query.js'; +import { filterServableSlugs } from './servable-slugs.js'; const LOG = cds.log('semantic-search'); @@ -149,7 +150,11 @@ function hanaVecStr(q) { return '[' + Array.from(q, (x) => x.toFixed(6)).join(', // ---- Per-corpus retrieval ------------------------------------------------- async function searchTutorials({ db, qVec, model, topK }) { try { + let results; if (isHana(db)) { + // #2631: gate on Tutorials.status so soft-deleted (INACTIVE) tutorials — + // whose embeddings survive the delete — never surface. Mirrors the public + // predicate used by SearchableItems and the homepage shelves. const sql = ` SELECT TOP ${topK} e."STEPNUMBER", e."STEPTEXT", @@ -158,25 +163,36 @@ async function searchTutorials({ db, qVec, model, topK }) { FROM "COM_SAP_DEVELOPERS_IMS_TUTORIALEMBEDDING" e JOIN "COM_SAP_DEVELOPERS_IMS_TUTORIALS" t ON t."ID" = e."TUTORIAL_ID" WHERE e."EMBEDDINGMODEL" = ? + AND (t."STATUS" IS NULL OR t."STATUS" = 'ACTIVE') ORDER BY "score" DESC`; const rows = await db.run(sql, [hanaVecStr(qVec), model]); - return (rows || []).map((r) => shapeTutorial( + results = (rows || []).map((r) => shapeTutorial( r.slug ?? r.SLUG, r.title ?? r.TITLE, r.STEPNUMBER ?? r.stepNumber, r.STEPTEXT ?? r.stepText, r.score ?? r.SCORE)); + } else { + // SQLite: fetch model rows + tutorial index, rank in JS. The tutorial + // index is pre-filtered to ACTIVE/null so INACTIVE rows drop out of tMap. + const { TutorialEmbedding, Tutorials } = cds.entities('com.sap.developers.ims'); + const rows = await SELECT.from(TutorialEmbedding) + .columns('tutorial_ID', 'stepNumber', 'stepText', 'embedding') + .where({ embeddingModel: model }); + const tIndex = await SELECT.from(Tutorials) + .columns('ID', 'slug', 'title') + .where(`status is null or status = 'ACTIVE'`); + const tMap = new Map(tIndex.map((t) => [t.ID, t])); + results = (rows || []).map((r) => { + const v = decodeF32(r.embedding); + if (!v) return null; + const t = tMap.get(r.tutorial_ID); + if (!t) return null; // INACTIVE / missing tutorial → excluded + return shapeTutorial(t.slug, t.title, r.stepNumber, r.stepText, cosine(v, qVec)); + }).filter(Boolean); } - // SQLite: fetch model rows + tutorial index, rank in JS. - const { TutorialEmbedding, Tutorials } = cds.entities('com.sap.developers.ims'); - const rows = await SELECT.from(TutorialEmbedding) - .columns('tutorial_ID', 'stepNumber', 'stepText', 'embedding') - .where({ embeddingModel: model }); - const tIndex = await SELECT.from(Tutorials).columns('ID', 'slug', 'title'); - const tMap = new Map(tIndex.map((t) => [t.ID, t])); - return (rows || []).map((r) => { - const v = decodeF32(r.embedding); - if (!v) return null; - const t = tMap.get(r.tutorial_ID) || {}; - return shapeTutorial(t.slug, t.title, r.stepNumber, r.stepText, cosine(v, qVec)); - }).filter(Boolean); + // #2631: drop any ranked hit whose slug no longer has servable content in + // ContentCurrent (unpublished without a status flip, or stale embedding). + // Fail-open inside the helper keeps results if the check itself errors. + const servable = await filterServableSlugs(db, results.map((r) => r.slug)); + return results.filter((r) => servable.has(r.slug)); } catch (err) { LOG.warn('tutorials corpus failed:', err.message); return []; diff --git a/srv/lib/servable-slugs.js b/srv/lib/servable-slugs.js new file mode 100644 index 000000000..6c54b5d62 --- /dev/null +++ b/srv/lib/servable-slugs.js @@ -0,0 +1,69 @@ +// srv/lib/servable-slugs.js +// +// #2631 — shared content-presence gate for anonymous MCP tutorial search. +// +// Soft-deleting a tutorial flips Tutorials.status to INACTIVE (the row and its +// embeddings are kept — see srv/admin-service.js), and a slug can also fall out +// of the latest content publish without any status change. In both cases the +// website serve path 404s because there is no servable BLOB, but the search +// surfaces trusted only the metadata (the Tutorials row / SearchableItems view) +// and still returned a /tutorials/ link that now 404s (the Joule Desktop +// "broken link" bug). ContentCurrent is the single source of truth the serve +// path uses: one row per slug that currently has servable HTML. +// +// filterServableSlugs() takes the candidate slugs and returns the subset that +// still has a ContentCurrent row, so both search tools can drop the rest. +// +// Dual dialect (mirrors srv/lib/semantic-search.js): +// - HANA: raw db.run with quoted-uppercase identifiers. +// - SQLite (unit tests): raw db.run against the lowercase table. +// The IN list is chunked (same HANA bound-param packet concern as the tag +// filters in srv/search-service.js). Fail-open: on ANY error we return every +// input slug, so a backfill gap or DB hiccup never blanks search results — the +// same fail-open contract the semantic-search module already follows. + +import cds from '@sap/cds'; + +const LOG = cds.log('servable-slugs'); + +const HANA_TABLE = 'COM_SAP_DEVELOPERS_IMS_CONTENTCURRENT'; +const SQLITE_TABLE = 'com_sap_developers_ims_ContentCurrent'; +// Keep each IN list well under any HANA bound-param packet limit. +const CHUNK = 500; + +function isHana(db) { + return db?.kind === 'hana' || db?.options?.kind === 'hana' || db?.constructor?.name === 'HANAService'; +} + +/** + * Return the subset of `slugs` that currently has servable content in + * ContentCurrent, as a Set (lowercased, matching how both callers compare). + * + * @param {object} db cds.db (or a compatible { run, kind }). + * @param {string[]} slugs Candidate slugs (any case; compared case-insensitively). + * @returns {Promise>} Present slugs. On error, ALL input slugs (fail-open). + */ +export async function filterServableSlugs(db, slugs) { + const list = [...new Set((slugs || []).map((s) => (s || '').toLowerCase()).filter(Boolean))]; + if (!list.length) return new Set(); + + try { + const table = isHana(db) ? `"${HANA_TABLE}"` : SQLITE_TABLE; + const col = isHana(db) ? '"SLUG"' : 'slug'; + const present = new Set(); + for (let i = 0; i < list.length; i += CHUNK) { + const chunk = list.slice(i, i + CHUNK); + const placeholders = chunk.map(() => '?').join(','); + const sql = `SELECT ${col} AS "slug" FROM ${table} WHERE LOWER(${col}) IN (${placeholders})`; + const rows = await db.run(sql, chunk); + for (const r of rows || []) { + const s = (r.slug ?? r.SLUG ?? '').toLowerCase(); + if (s) present.add(s); + } + } + return present; + } catch (err) { + LOG.warn('content-presence check failed; failing open (keeping all slugs):', err.message); + return new Set(list); + } +} diff --git a/srv/search-service.js b/srv/search-service.js index f26d6bafb..39f8cb9c9 100644 --- a/srv/search-service.js +++ b/srv/search-service.js @@ -6,6 +6,7 @@ import { handleSearchEvents } from './lib/mcp-events-search.js'; import { handleSearchTechEd } from './lib/mcp-teched-search.js'; import { handleSearchChannels } from './lib/mcp-channels-search.js'; import { semanticSearch, clampTopK } from './lib/semantic-search.js'; +import { filterServableSlugs } from './lib/servable-slugs.js'; import { resolveSearchSettings } from './lib/runtime-config/search-settings.js'; import { createIpRateLimiter, IpRateLimitError } from './lib/ip-rate-limit.js'; @@ -356,12 +357,18 @@ export default class SearchService extends cds.ApplicationService { if (experience) q.where({ experienceTag: experience }); const rows = await cds.db.run(q); - return rows.map(r => ({ - slug: (r.slug ?? '').toLowerCase(), - title: r.title ?? '', - snippet: (r.description ?? '').slice(0, 240), - tags: r.primaryTag ? [r.primaryTag] : [], - })); + // #2631: drop any hit whose slug has no servable content in ContentCurrent + // (soft-deleted / unpublished — the /tutorials/ link would 404). + // Fail-open inside the helper keeps results if the check itself errors. + const servable = await filterServableSlugs(cds.db, rows.map(r => r.slug)); + return rows + .filter(r => servable.has((r.slug ?? '').toLowerCase())) + .map(r => ({ + slug: (r.slug ?? '').toLowerCase(), + title: r.title ?? '', + snippet: (r.description ?? '').slice(0, 240), + tags: r.primaryTag ? [r.primaryTag] : [], + })); }); /** diff --git a/test/search-service.test.js b/test/search-service.test.js index 6d824e73c..08c23d275 100644 --- a/test/search-service.test.js +++ b/test/search-service.test.js @@ -60,6 +60,21 @@ describe('SearchService', () => { { slug: 'hana-cloud-setup', bodyText: 'Open the BTP cockpit and provision a HANA Cloud instance. Configure the firewall ipallowlist before connecting.' }, { slug: 'cap-getting-started', bodyText: 'Run cds init to scaffold a project. Add an entity to db schema and a service projection.' }, ]); + + // #2631: the search_tutorials MCP tool now drops any slug that has no + // servable content in ContentCurrent (the serve-path source of truth), so a + // soft-deleted / unpublished tutorial never surfaces with a /tutorials/ + // link that 404s. Seed ContentCurrent for every ACTIVE fixture tutorial that + // SHOULD remain searchable; deliberately omit `fiori-elements` to prove the + // content-presence gate drops a metadata row with no servable BLOB. + const { ContentCurrent } = cds.entities('com.sap.developers.ims'); + await INSERT.into(ContentCurrent).entries([ + { slug: 'hana-cloud-setup' }, + { slug: 'cap-getting-started' }, + { slug: 'tag-only-1' }, { slug: 'tag-only-2' }, { slug: 'tag-only-3' }, + { slug: 'tag-only-4' }, { slug: 'tag-only-5' }, + { slug: 'rankprobe-tutorial' }, { slug: 'rankprobe-desc-tutorial' }, + ]); }); describe('SearchableItems', () => { @@ -328,4 +343,24 @@ describe('SearchService', () => { expect(Array.isArray(data.value)).toBe(true); }); }); + + describe('search_tutorials (MCP tool)', () => { + async function searchTutorials(data) { + const srv = await cds.connect.to('SearchService'); + return srv.send({ event: 'search_tutorials', data }); + } + + it('returns servable tutorials matching the query', async () => { + const rows = await searchTutorials({ query: 'hana' }); + expect(rows.map(r => r.slug)).toContain('hana-cloud-setup'); + }); + + // #2631: an ACTIVE tutorial present in SearchableItems but absent from + // ContentCurrent (unpublished without a status flip) must be dropped — its + // /tutorials/ link 404s. fiori-elements has no ContentCurrent row. + it('excludes an ACTIVE tutorial with no servable content', async () => { + const rows = await searchTutorials({ query: 'fiori' }); + expect(rows.map(r => r.slug)).not.toContain('fiori-elements'); + }); + }); }); diff --git a/test/unit/semantic-search.test.js b/test/unit/semantic-search.test.js index c043abfe7..73521c9d3 100644 --- a/test/unit/semantic-search.test.js +++ b/test/unit/semantic-search.test.js @@ -47,19 +47,37 @@ function installFakeEmbed() { } async function seedCorpora() { - const { Tutorials, TutorialEmbedding, Concepts } = cds.entities(NS); + const { Tutorials, TutorialEmbedding, Concepts, ContentCurrent } = cds.entities(NS); const { ApiDocs, Samples, DevtoberfestSessions, TechEdSessions } = cds.entities(`${NS}.external`); // Tutorials: alpha scores 1.0 (dim 0), beta scores 0 (dim 1 → below floor). + // #2631: both are ACTIVE and both have servable content in ContentCurrent so + // the status + content-presence gates let them through unchanged. + // - gone-inactive: ACTIVE-scoring embedding (dim 0) but status=INACTIVE + // (soft-deleted) → must be dropped by the status gate. + // - gone-nocontent: ACTIVE + ACTIVE-scoring embedding but NO ContentCurrent + // row → must be dropped by the content-presence gate. await INSERT.into(Tutorials).entries([ - { ID: 'tid-alpha', slug: 'tut-alpha', title: 'Tutorial Alpha' }, - { ID: 'tid-beta', slug: 'tut-beta', title: 'Tutorial Beta' }, + { ID: 'tid-alpha', slug: 'tut-alpha', title: 'Tutorial Alpha', status: 'ACTIVE' }, + { ID: 'tid-beta', slug: 'tut-beta', title: 'Tutorial Beta', status: 'ACTIVE' }, + { ID: 'tid-inactive', slug: 'tut-gone-inactive', title: 'Deleted Tutorial', status: 'INACTIVE' }, + { ID: 'tid-nocontent', slug: 'tut-gone-nocontent', title: 'Unpublished Tutorial', status: 'ACTIVE' }, ]); await INSERT.into(TutorialEmbedding).entries([ { tutorial_ID: 'tid-alpha', stepNumber: 1, embeddingModel: 'text-embedding-3-small', stepText: 'Alpha step text about CAP', embedding: f32buf(unitVec(0)) }, { tutorial_ID: 'tid-beta', stepNumber: 1, embeddingModel: 'text-embedding-3-small', stepText: 'Beta step text', embedding: f32buf(unitVec(1)) }, + { tutorial_ID: 'tid-inactive', stepNumber: 1, embeddingModel: 'text-embedding-3-small', + stepText: 'Deleted step text about CAP', embedding: f32buf(unitVec(0)) }, + { tutorial_ID: 'tid-nocontent', stepNumber: 1, embeddingModel: 'text-embedding-3-small', + stepText: 'Unpublished step text about CAP', embedding: f32buf(unitVec(0)) }, + ]); + // Servable content exists only for the two live tutorials. tut-gone-inactive + // and tut-gone-nocontent have no ContentCurrent row. + await INSERT.into(ContentCurrent).entries([ + { slug: 'tut-alpha' }, + { slug: 'tut-beta' }, ]); // Concept: ACTIVE + published + not merged, scores 1.0. @@ -151,6 +169,20 @@ describe('semanticSearch (core module)', () => { expect(rows[0].snippet).toContain('Alpha step text'); }); + // #2631: soft-deleted (status=INACTIVE) tutorials keep their embeddings but + // must never surface — their /tutorials/ link now 404s. + it('tutorials corpus: excludes a soft-deleted (INACTIVE) tutorial even with a top-scoring embedding', async () => { + const rows = await semanticSearch({ query: 'q-inactive', corpus: 'tutorials', settings }); + expect(rows.map((r) => r.slug)).not.toContain('tut-gone-inactive'); + }); + + // #2631: an ACTIVE tutorial whose content was unpublished (no ContentCurrent + // row) must also be dropped — the metadata row survives but the link 404s. + it('tutorials corpus: excludes an ACTIVE tutorial with no servable content', async () => { + const rows = await semanticSearch({ query: 'q-nocontent', corpus: 'tutorials', settings }); + expect(rows.map((r) => r.slug)).not.toContain('tut-gone-nocontent'); + }); + it('never leaks a vector/embedding in the wire shape', async () => { const rows = await semanticSearch({ query: 'q-shape', corpus: 'all', settings }); expect(rows.length).toBeGreaterThan(0); diff --git a/test/unit/servable-slugs.test.js b/test/unit/servable-slugs.test.js new file mode 100644 index 000000000..df735e1fe --- /dev/null +++ b/test/unit/servable-slugs.test.js @@ -0,0 +1,52 @@ +// test/unit/servable-slugs.test.js +// +// #2631 — unit tests for filterServableSlugs(): the shared content-presence +// gate that both anonymous MCP tutorial-search surfaces (SearchService +// search_tutorials and semantic_search) use to drop slugs that no longer have +// servable content in ContentCurrent (so a soft-deleted / unpublished tutorial +// never surfaces with a /tutorials/ link that 404s). +// +// In-memory SQLite; the helper is dual-dialect + fail-open. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'node:path'; +import cds from '@sap/cds'; + +import { filterServableSlugs } from '../../srv/lib/servable-slugs.js'; + +const NS = 'com.sap.developers.ims'; + +describe('filterServableSlugs', () => { + beforeAll(async () => { + await cds.deploy([path.join(process.cwd(), 'db')]).to('sqlite::memory:'); + const { ContentCurrent } = cds.entities(NS); + await INSERT.into(ContentCurrent).entries([ + { slug: 'live-a' }, + { slug: 'live-b' }, + ]); + }); + + afterAll(async () => { + await cds.disconnect(); + delete cds.db; + delete cds.model; + }); + + it('returns a Set of only the slugs present in ContentCurrent', async () => { + const set = await filterServableSlugs(cds.db, ['live-a', 'gone-x', 'live-b']); + expect(set instanceof Set).toBe(true); + expect([...set].sort()).toEqual(['live-a', 'live-b']); + }); + + it('empty input returns an empty Set (no query)', async () => { + const set = await filterServableSlugs(cds.db, []); + expect(set instanceof Set).toBe(true); + expect(set.size).toBe(0); + }); + + it('fails open: on a query error, keeps every input slug', async () => { + const brokenDb = { run: async () => { throw new Error('boom'); }, kind: 'sqlite' }; + const set = await filterServableSlugs(brokenDb, ['a', 'b']); + expect([...set].sort()).toEqual(['a', 'b']); + }); +}); From 39c32101606165851e6bd03d26a7d326323ca0ff Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 08:53:51 -0400 Subject: [PATCH 04/37] feat(signin): nudge to sign out before switching IdP over active session (#2626 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Known limitation from 1.34.0: on /signin, clicking a provider while an IAS SSO session is active silently reuses that session's IdP instead of the chosen one. A true fix needs OIDC prompt=login, which the approuter cannot forward, and IAS Conditional/Risk-Based Authentication on tenant atxgsg7zi offers no idp-keyed force-reauth action (verified live via admin console) — only an all-or-nothing per-app Force Authentication that would break the silent returning-visitor path. Proportionate client-side fix instead of standing up a second force-auth IAS app: make the state visible. When header.html's checkAuth resolves to an authenticated session (data-authenticated / auth-resolved), reveal a hint on /signin telling the user to sign out first to switch providers. Hidden by default; reuses window.handleLogout for the sign-out. No IAS/XSUAA/approuter change; does not touch the seamless maybeAutoLogin path. --- hugo/layouts/signin.html | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/hugo/layouts/signin.html b/hugo/layouts/signin.html index e202c709d..2078cdb88 100644 --- a/hugo/layouts/signin.html +++ b/hugo/layouts/signin.html @@ -53,6 +53,21 @@

Sign In

+ {{/* + Active-session nudge (follow-up to #2626). When the visitor already has an + IAS SSO session, clicking a provider silently reuses that session's IdP + instead of the chosen one (the approuter can't forward OIDC prompt=login, + and IAS has no idp-keyed force-reauth — verified on tenant atxgsg7zi). We + can't force re-auth without breaking the silent returning-visitor path, so + we make the state visible: tell the user to sign out first to switch IdP. + Hidden by default; revealed only when header.html's checkAuth resolves to + an authenticated session (data-authenticated="true" / auth-resolved event). + */}} + +

Sign In

a.href += '&returnTo=' + encodeURIComponent(rt); }); })(); + + // Active-session nudge (#2626 follow-up). header.html's checkAuth() already + // probes /auth/user and records the result on plus + // an 'auth-resolved' event. maybeAutoLogin is suppressed on /signin, so there + // is no redirect race — we only reveal the hint when a session is confirmed. + (function () { + var nudge = document.getElementById('signin-session-nudge'); + if (!nudge) return; + + function reveal(isAuthed) { + nudge.hidden = !isAuthed; + } + + // Catch the event if it fires after this script, and the already-resolved + // case where the attribute is set before we attach the listener. + document.addEventListener('auth-resolved', function (e) { + reveal(!!(e.detail && e.detail.authenticated)); + }); + if (document.documentElement.dataset.authenticated === 'true') reveal(true); + + var logout = document.getElementById('signin-session-logout'); + if (logout) { + logout.addEventListener('click', function (e) { + e.preventDefault(); + // Reuse header.html's logout (sets autologin.optout, clears joule.* + // session keys, then redirects to /logout). Fall back to /logout if the + // header hasn't exposed it yet. + if (typeof window.handleLogout === 'function') window.handleLogout(); + else window.location.href = '/logout'; + }); + } + })(); {{ end }} From 116510e60ec527f3b55153c24780b033dde7799f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:01:54 -0400 Subject: [PATCH 05/37] fix(mcp): pin attr.dbUserId on PAT synthetic user so resolvers resolve (#2630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installSyntheticUser stored the resolved Users.ID on req.user._dbUserId, but resolveDbUser (resolve-db-user.js) and resolveDbUserId (user-progress.js) both fast-path on req.user.attr.dbUserId. A PAT carries no iss/sub/email token payload, and migrated legacy users frequently have a NULL sapId, so resolution fell through to `WHERE sapId = ` and missed — surfacing as 401 "unable to resolve user" on the strict MCP tools (get_my_tutorials/missions/ events/completed_steps) and silent [] on the lenient reads (getMyCompletions/ getMyInProgress), even though the PAT already knew the exact Users row. Pin attr.dbUserId = cached.userId so the shared fast-path hits. Adds a red->green unit test asserting the no-sapId PAT user resolves end-to-end via resolveDbUser to the correct Users.ID. --- srv/lib/mcp-pat-middleware.js | 9 ++++++++- test/unit/mcp-pat-middleware.test.js | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/srv/lib/mcp-pat-middleware.js b/srv/lib/mcp-pat-middleware.js index e35555f8e..062887b28 100644 --- a/srv/lib/mcp-pat-middleware.js +++ b/srv/lib/mcp-pat-middleware.js @@ -110,7 +110,14 @@ function installSyntheticUser(req, cached) { is: (role) => role === 'authenticated-user' || scopeRoles.has(role) || (Array.isArray(cached.roles) && cached.roles.includes(role)), - attr: cached.attr ?? {}, + // Pin the resolved Users.ID where the shared resolvers look for it. + // resolveDbUser (resolve-db-user.js) and resolveDbUserId (user-progress.js) + // both fast-path on `user.attr.dbUserId`; a PAT carries no iss/sub/email + // token payload and migrated users often have a NULL sapId, so without this + // pin resolution falls through to `WHERE sapId = ` and misses — + // yielding 401 "unable to resolve user" on strict MCP tools and silent [] + // on lenient reads even though the PAT already knows the exact Users row (#2630). + attr: { ...(cached.attr ?? {}), dbUserId: cached.userId }, tokenSource: 'pat', authInfo: { token: { userId: cached.sapId } }, _dbUserId: cached.userId, diff --git a/test/unit/mcp-pat-middleware.test.js b/test/unit/mcp-pat-middleware.test.js index e75678e94..108077d4d 100644 --- a/test/unit/mcp-pat-middleware.test.js +++ b/test/unit/mcp-pat-middleware.test.js @@ -101,6 +101,27 @@ describe('mcp-pat-middleware', () => { expect(req.user.is('pat-write')).toBe(false); }); + it('pins attr.dbUserId so the shared resolvers resolve a no-sapId PAT (#2630)', async () => { + // Regression: installSyntheticUser used to put the resolved Users.ID on + // req.user._dbUserId, but resolveDbUser / resolveDbUserId fast-path on + // req.user.attr.dbUserId. A PAT carries no iss/sub/email token payload and + // the seed user has NO sapId (the migrated-legacy-user case), so without the + // attr.dbUserId pin resolution fell through to `WHERE sapId = ` and + // missed → 401 on strict MCP tools, silent [] on lenient reads (#2630). + const req = mockReq('Bearer pat_abcd1234_' + 'a'.repeat(48)); + const res = mockRes(); const next = vi.fn(); + await patMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + expect(req.user.attr?.dbUserId).toBe('mw-user-uuid'); + + // End-to-end: the shared strict resolver must now return the exact Users row + // the PAT points at, despite the user having no sapId. This is the assertion + // that fails on the pre-fix code. + const { resolveDbUser } = await import('../../srv/lib/resolve-db-user.js'); + const dbUser = await resolveDbUser(req.user); + expect(dbUser?.ID).toBe('mw-user-uuid'); + }); + it('strips the Authorization header on a valid PAT (#1105 — xsuaa must not JWT-parse it)', async () => { // The core production fix: after PAT auth, the Bearer pat_ header MUST be // removed so CAP's downstream xsuaa/ias strategy no-ops (jwt-auth returns From ea4b48145c61e5713e0370f6653382faeb3feb58 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:11:42 -0400 Subject: [PATCH 06/37] docs: register signin-sso-reauth-followup in sidebar Fixes Deploy Docs CI failure. PR #2626 added the operations page to DEV but did not register it in the VitePress sidebar, so the check-docs-sidebar gate failed docs:build on push to DEV. --- docs/.vitepress/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index fdd86b3df..ef49d0dad 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -204,6 +204,7 @@ export default defineConfig({ { text: 'Runtime config', link: '/developers/operations/runtime-config' }, { text: 'Scheduler troubleshooting', link: '/developers/operations/scheduler-troubleshooting' }, { text: 'Secrets tracking', link: '/developers/operations/secrets-tracking' }, + { text: 'Sign-in SSO re-auth follow-up', link: '/developers/operations/signin-sso-reauth-followup' }, { text: 'SMTP credentials rotation', link: '/developers/operations/smtp-credentials-rotation' }, { text: 'Testing endpoints', link: '/developers/operations/testing-endpoints' }, { text: 'Testing guide', link: '/developers/operations/testing-guide' }, From 8239bfe43ea0d9975cb0cc741e25a2296a104931 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:24:32 -0400 Subject: [PATCH 07/37] docs(#2585): design spec for tutorial quarantine persistence + Admin UI --- ...-tutorial-quarantine-persistence-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-tutorial-quarantine-persistence-design.md diff --git a/docs/superpowers/specs/2026-10-04-tutorial-quarantine-persistence-design.md b/docs/superpowers/specs/2026-10-04-tutorial-quarantine-persistence-design.md new file mode 100644 index 000000000..b602200f3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-tutorial-quarantine-persistence-design.md @@ -0,0 +1,277 @@ +# Tutorial Quarantine Persistence + Admin UI — Design + +**Issue:** sap-tutorials/tutorials-ims#2585 +**Date:** 2026-10-04 +**Author:** Claude (with Tom) +**Status:** Draft — awaiting review + +## Problem + +`scripts/validate-tutorials.ts` quarantines tutorials that fail pre-publish +validation by `renameSync`-ing the offending `.md` into +`.tutorial-cache/quarantine/`, writing `errors.json`, and exiting 0. That +directory lives only on the ephemeral GitHub Actions runner for +`rebuild-content.yml` and is discarded when the run ends. Nothing consumes +`errors.json`; there is no alert and no Admin UI surface. A tutorial can silently +drop out of the ACTIVE content snapshot (and 404 on PROD) with no durable record +of *why* — confirmed live for `btp-transport-management-cpi-01-use-case` and +`private-link-gcp`. + +## Goal + +Persist quarantine results centrally in HANA and surface them in the Admin UI so +they are not lost, with a view that reflects **current** state (not +history-forever) plus per-repo / over-time reporting. + +## Scope decisions (confirmed with Tom) + +1. **Snapshot per build.** Each run that writes a snapshot records its complete + current quarantine set, tagged with `runId` + `manifestVersion`. The Admin + view shows only the **latest snapshot's** set. A slug that now passes is + simply absent from the new snapshot → auto-cleared. History is retained by + build for reporting. +2. **DB + Admin view only.** No alerting wiring in this issue — alerting can be a + follow-up that consumes the new table. +3. **Full rebuilds only write snapshots.** `mode=full` posts the complete + quarantine set. Slug-targeted/catalog runs validate only a subset, so their + quarantine set is partial and would wrongly "clear" out-of-scope slugs — they + do **not** post. This matches the issue framing (carry-forward masks failures + until a full rebuild). +4. **Fail-open on post failure.** If the authenticated POST to HANA fails, the + validator logs a warning and still exits 0 — never block content publish on + quarantine telemetry. Matches the validator's existing never-exit-non-zero + contract and the repo's fail-open ethos. + +## Architecture + +```text +rebuild-content.yml (mode=full) + → npm run validate-tutorials (scripts/validate-tutorials.ts) + · validates hugo/content/tutorials/*.md (unchanged logic) + · builds quarantined[] = { file, slug, reason } (unchanged) + · NEW: if full build AND CAP_BASE_URL+CONTENT_API_KEY present, + POST the snapshot → /content/quarantine-events (best-effort) + ↓ Authorization: Bearer +CAP srv → POST /content/quarantine-events (raw Express + contentAuthMiddleware) + · quarantineIngestHandler (packages/content/content-store.js) + · one DB write per snapshot: new snapshot row + its events, + marked as the current snapshot (supersedes prior current) + ↓ +HANA QuarantineSnapshots (1 per build) + QuarantineEvents (N per snapshot) + ↑ +AdminService (/admin, @requires:'Admin') + → @readonly projections → Fiori Elements list view at /admin-ui/#quarantine-events +``` + +### Why two entities + +A snapshot header (`QuarantineSnapshots`) + child events (`QuarantineEvents`) +cleanly expresses "the current set is the latest snapshot's children." The +"current view" is `events where snapshot.isCurrent`. Reporting (count over time / +per repo) is `events` grouped by `snapshot.createdAt` / `event.sourceRepo`. +This avoids per-slug upsert/clear bookkeeping — a snapshot is atomic and +immutable once written; "clearing" is just a new snapshot not containing the slug. + +## Data model — `db/tutorial-quarantine.cds` (NEW) + +```cds +namespace com.sap.developers.ims; +using { com.sap.developers.ims as ims } from './schema'; +using { cuid, managed } from '@sap/cds/common'; + +/** + * One row per build that posts a quarantine snapshot (mode=full only, #2585). + * The newest snapshot with isCurrent = true defines the live quarantine set; + * writing a new current snapshot flips the prior one to false. + */ +entity QuarantineSnapshots : cuid, managed { + runId : String(60); // github.run_id + workflowUrl : String(400); // server_url/repo/actions/runs/ + manifestVersion : String(60); // ContentManifest version at build time (nullable) + buildMode : String(20) default 'full'; // full | (future: catalog/slug) + isCurrent : Boolean default false; + eventCount : Integer default 0; + events : Composition of many QuarantineEvents on events.snapshot = $self; +} + +/** + * One quarantined slug within a snapshot: why it was dropped and where it came from. + */ +entity QuarantineEvents : cuid, managed { + snapshot : Association to QuarantineSnapshots; + slug : String(255) @mandatory; // lowercase canonical slug + sourceFile : String(255); // e.g. foo.md + sourceRepo : String(120); // from _discovery.json repoBySlug + reason : String(500) @mandatory; // validator reason string + sourceUrl : String(600); // link to the source .md in its repo (nullable) +} +``` + +Notes: +- `cuid, managed` → UUID `ID` + `createdAt/createdBy/modifiedAt/modifiedBy` + (house style, matches `db/tutorial-freshness.cds`). +- `isCurrent` flip is done inside the ingest handler in one transaction: + `UPDATE QuarantineSnapshots SET isCurrent=false WHERE isCurrent=true`, then + `INSERT` the new snapshot with `isCurrent=true` and its events. +- `sourceUrl` derived from `sourceRepo` + `sourceFile` where resolvable; else null. + +## Ingest endpoint — `/content/quarantine-events` (NEW) + +Raw Express route, mirroring `/content/pipeline-log` exactly (the closest +sibling: single-shot JSON POST + DB write, same auth). + +- **Register** in `srv/server.js` near the other content routes (line ~974): + ```js + app.post('/content/quarantine-events', + express.json({ limit: '2mb' }), contentAuthMiddleware, quarantineIngestHandler); + ``` +- **Handler** `quarantineIngestHandler` lives in `packages/content/content-store.js` + (inside `createContentHandlers`), exported through the `srv/lib/content-store.js` + shim like the other handlers. +- **Auth**: reuses existing `contentAuthMiddleware` (Bearer = `CONTENT_API_KEY`, + `timingSafeEqual`; 503 unconfigured / 401 missing bearer / 403 wrong key). +- **Request body**: + ```json + { + "runId": "123456", + "workflowUrl": "https://github.com/.../actions/runs/123456", + "manifestVersion": "...", // optional + "buildMode": "full", + "events": [ + { "slug": "...", "sourceFile": "...md", "sourceRepo": "...", + "reason": "...", "sourceUrl": "..." } + ] + } + ``` +- **Behaviour**: validate `buildMode === 'full'` (reject others 400 — only full + builds define a complete set); within one tx flip prior `isCurrent` → false, + insert snapshot (`isCurrent=true`, `eventCount=events.length`) + child events. + Return `{ snapshotId, eventCount }`. Idempotency: a repeat POST with the same + `runId` replaces that run's snapshot (delete-by-runId then insert) so retries + don't double-count. +- **Route-drift guard**: this route is content-publish-adjacent and should exist + on both srv and srv-qa; if prod-only, allowlist in + `scripts/check-srv-qa-route-drift.ts`. (Will verify at implementation time.) + +## AdminService projection — `srv/admin-service.cds` (EDIT) + +```cds +using from '../db/tutorial-quarantine'; // near lines 1-15 +... +@readonly entity QuarantineSnapshots as projection on ims.QuarantineSnapshots; +@readonly entity QuarantineEvents as projection on ims.QuarantineEvents; +``` + +Service is already `@path:'/admin'`, `@requires:'Admin'`. No MCP/HCQL changes. + +## Admin UI view — `app/admin/quarantine-events/` (NEW) + annotations (EDIT) + +Mirror `app/admin/channels/`. The admin-shell manifest is **generated** by +`discover-admin-components.js` — any `app/admin//webapp/{Component.js,manifest.json}` +is auto-discovered; **no shell manifest edit**. + +Files: +1. `app/admin/quarantine-events/webapp/Component.js` — 3-line + `AppComponent.extend("sap.tutorials.admin.quarantineEvents.Component", ...)`. +2. `app/admin/quarantine-events/webapp/manifest.json` — Fiori Elements LR/OP, + `sap.app.id = sap.tutorials.admin.quarantineEvents` (enforced at discover + time), dataSource `mainService` → `/admin/`, ListReport contextPath + `/QuarantineEvents`, ObjectPage, `crossNavigation.inbounds` entry + `QuarantineEvents-manage`. +3. `app/admin/quarantine-events/webapp/i18n/i18n.properties`. +4. **EDIT** `app/admin-annotations.cds` — add `@UI` for `AdminService.QuarantineEvents` + (template: `StepFailures` block). LineItem columns: `slug`, `reason`, + `sourceRepo`, `sourceFile`, `createdAt` (= last-seen build time); + SelectionFields: `sourceRepo`, `createdAt`; read-only + (Delete/Insert/Update Restrictions all false); `sourceUrl` as a link field. + +The view binds to `QuarantineEvents` filtered to the current snapshot. Two +options for "current only": +- **(a)** Expose a `QuarantineEventsCurrent` view/projection + (`projection on ims.QuarantineEvents where snapshot.isCurrent`) and bind the LR + to it — cleanest for the user (no filter needed). +- **(b)** Bind to `QuarantineEvents` with a default filter on `snapshot/isCurrent eq true`. + +**Chosen: (a)** — a dedicated `@readonly QuarantineEventsCurrent` projection, so +the default Admin view is already "current" with no filter ceremony, while the +raw `QuarantineEvents` + `QuarantineSnapshots` projections remain available for +over-time / per-repo reporting. + +**Deploy constraint (from CLAUDE.md):** admin-UI changes need a FULL deploy +(`npm run deploy -- --env `, no `--skip-build`, no `-m` scoping); Step 3.5 +`check-shipped-admin-bundle.cjs` gates on bundle drift. + +## Validator change — `scripts/validate-tutorials.ts` (EDIT) + +- Keep all existing logic (quarantine → move file → errors.json). Add `slug` and + `sourceRepo` to each `quarantined[]` entry (slug from frontmatter, repo from + the existing `repoBySlug` map — both already loaded). +- After the loop, **best-effort POST** the snapshot: + - Guard: only when `process.env.CAP_BASE_URL` **and** `process.env.CONTENT_API_KEY` + are set **and** the build is a full build. Full-build signal: a new + `QUARANTINE_REPORT=full` env (set only by the rebuild-content.yml full path) + — avoids posting from the `mta.yaml` before-all build and local runs where no + backend exists. (Simpler and more explicit than inferring mode.) + - Mirror `publish-content.ts` fetch: `POST ${CAP_BASE_URL}/content/quarantine-events`, + `Authorization: Bearer ${CONTENT_API_KEY}`, `Content-Type: application/json`, + `x-initiator: ci/${RUN_ID}`. Body = `{ runId, workflowUrl, buildMode:'full', events }`. + - Wrap in try/catch: on non-ok or thrown error, `console.warn` and continue; + **never throw, never exit non-zero** (fail-open). +- An **empty** quarantine set on a full build still posts (so a build that fixes + everything writes an empty current snapshot → view clears). This is how + auto-clear happens. + +## Workflow change — `.github/workflows/rebuild-content.yml` (EDIT) + +Add an `env:` block to the "Validate tutorials" step (lines 631-633). Only set +`QUARANTINE_REPORT=full` when the effective mode is full: + +```yaml +- name: Validate tutorials + if: ${{ steps.mode.outputs.effective_mode != 'catalog-only' }} + env: + CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} + CONTENT_API_KEY: ${{ steps.env.outputs.target == 'qa' && secrets.CONTENT_API_KEY_QA || secrets.CONTENT_API_KEY }} + RUN_ID: ${{ github.run_id }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + QUARANTINE_REPORT: ${{ steps.mode.outputs.effective_mode == 'full' && 'full' || '' }} + run: npm run validate-tutorials +``` + +(Verify exact step-id names — `steps.mode.outputs.effective_mode`, +`steps.srv.outputs.srv_url`, `steps.env.outputs.target` — against the current +file at implementation time. Mirror into `rebuild-content-qa.yml` if it has its +own validate step.) + +## Testing + +- **Unit (fast, in-memory SQLite):** + - `stepCountReason` / `shortcodeBalanceCheck` / `emptyContentCheck` — unchanged, + existing tests stay green. + - New: `quarantineIngestHandler` route test under `srv/__tests__/lib/` + (template: `content-publish-routes.test.js`): 503/401/403 auth matrix; + happy path inserts snapshot + events, flips prior `isCurrent`; non-full + `buildMode` → 400; repeat `runId` replaces (idempotent); empty events → empty + current snapshot. + - New: `QuarantineEventsCurrent` returns only current-snapshot events. + - New (validator): extract the POST into a small testable function; assert it + no-ops when env unset and when `QUARANTINE_REPORT !== 'full'`, and that a + failed fetch does not throw / exit non-zero. +- **Hybrid (real HANA):** covered by `npm run test:hybrid` for the entity + + endpoint (LOB-free, so CDS QL is fine). +- **E2E:** optional advisory — admin list view renders; defer to the post-deploy + `e2e` job (self-skips without `SMOKE_BASE_URL`). + +## Non-goals + +- Alerting / NGDS wiring (follow-up). +- Posting from slug-targeted or catalog runs. +- Serve-time 404 fixes (#3) — this issue only makes the *why* durable + visible. + +## Open items to verify during implementation + +1. Exact workflow step-ids in the *current* `rebuild-content.yml`. +2. Whether `rebuild-content-qa.yml` has its own validate step to mirror. +3. Whether the new Express route needs the srv-qa route-drift allowlist. +4. `manifestVersion` availability at validate time (nullable if not resolvable). +``` \ No newline at end of file From 4d20f864caee6a1751efec8c8b86a70963029446 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:40:43 -0400 Subject: [PATCH 08/37] feat(#2585): QuarantineSnapshots + QuarantineEvents entities --- db/tutorial-quarantine.cds | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 db/tutorial-quarantine.cds diff --git a/db/tutorial-quarantine.cds b/db/tutorial-quarantine.cds new file mode 100644 index 000000000..ae0c44aba --- /dev/null +++ b/db/tutorial-quarantine.cds @@ -0,0 +1,33 @@ +namespace com.sap.developers.ims; + +using { com.sap.developers.ims as ims } from './schema'; +using { cuid, managed } from '@sap/cds/common'; + +/** + * One row per full rebuild that posts a quarantine snapshot (#2585). + * The newest snapshot with isCurrent = true defines the live quarantine set; + * writing a new current snapshot flips the prior one to false. Snapshots are + * immutable once written — "clearing" a slug is just a later snapshot that + * does not contain it. + */ +entity QuarantineSnapshots : cuid, managed { + runId : String(60); // github.run_id + workflowUrl : String(400); // .../actions/runs/ + manifestVersion : String(60); // ContentManifest version at build time (nullable) + buildMode : String(20) default 'full'; // full (only full builds post) + isCurrent : Boolean default false; + eventCount : Integer default 0; + events : Composition of many QuarantineEvents on events.snapshot = $self; +} + +/** + * One quarantined slug within a snapshot: why it was dropped and where it came from. + */ +entity QuarantineEvents : cuid, managed { + snapshot : Association to QuarantineSnapshots; + slug : String(255) @mandatory; // lowercase canonical slug + sourceFile : String(255); // e.g. foo.md + sourceRepo : String(120); // from fetch _discovery.json repoBySlug + reason : String(500) @mandatory; // validator reason string + sourceUrl : String(600); // link to source .md in its repo (nullable) +} From b436a3d60bb562667258b3be55b7e9a6dc9be161 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:45:54 -0400 Subject: [PATCH 09/37] fix(content): allow underscores in serve-side VALID_SLUG (#2587 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve path rejected tutorial slugs containing underscores before any DB lookup. The publish path (content-publish-session.js) stores slugs verbatim — underscores preserved — into ContentFiles/ContentCurrent, but serveHandler's VALID_SLUG=/^[a-z0-9][a-z0-9-]*$/ failed the regex and returned serveNotFound (404) for e.g. btp-integration-suite-creating_data_type-message_type despite the slug being present in ACTIVE snapshot v281 with correct stepCount. Widen the read-side gate to /^[a-z0-9][a-z0-9_-]*$/ so read and write agree. First char still must be [a-z0-9] (leading _/uppercase still 400). Safe for topic/concept slugs: slugifyTopic() folds every non-[a-z0-9] run to a hyphen at generation, so they never arrive here with an underscore. Tests: new underscore-slug serve cases (HTML + markdown alternate + mixed-case 301) in test/lib/content-store.test.js; malformed-slug test switched to a space (still 404 under the new contract). Comments updated in topic-slug.js, slug-utils.test.js, srv-qa/content-service.test.js. 69/69 across the three affected files. --- packages/content/content-store.js | 10 +++++- packages/core/topic-slug.js | 4 +-- test/lib/content-store.test.js | 52 ++++++++++++++++++++++++++++- test/slug-utils.test.js | 9 +++-- test/srv-qa/content-service.test.js | 5 +-- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/content/content-store.js b/packages/content/content-store.js index e80b3fd6e..e28ae9bbe 100644 --- a/packages/content/content-store.js +++ b/packages/content/content-store.js @@ -915,7 +915,15 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap // --- GET /content/tutorials/* --- - const VALID_SLUG = /^[a-z0-9][a-z0-9-]*$/; + // Underscores are permitted: source-repo tutorial folder names legitimately + // carry them (e.g. btp-integration-suite-creating_data_type-message_type) and + // the publish path stores the slug verbatim into ContentFiles/ContentCurrent + // (packages/content/content-publish-session.js — no _→- folding). Read and + // write MUST agree, or an underscore slug 404s at the gate below despite being + // in the ACTIVE snapshot (#2587 3a). Topic/concept slugs never reach here with + // an underscore — slugifyTopic() in packages/core/topic-slug.js folds every + // non-[a-z0-9] run to a hyphen at generation, so widening this is safe for them. + const VALID_SLUG = /^[a-z0-9][a-z0-9_-]*$/; // Render the published __404__ HTML page (or fall back to JSON if not published yet). // `status` lets callers reuse the styled page for other error classes — e.g. a diff --git a/packages/core/topic-slug.js b/packages/core/topic-slug.js index 1da7c7a5e..e3d5755b4 100644 --- a/packages/core/topic-slug.js +++ b/packages/core/topic-slug.js @@ -1,8 +1,8 @@ // URL-safe topic slug: lowercase, every run of non-[a-z0-9] → single hyphen, // trimmed. Real Tags.titlePath values carry spaces, colons and slashes // (e.g. "software product : technology platform / ... / open connectors"); -// the serve path (serveHandler VALID_SLUG=/^[a-z0-9][a-z0-9-]*$/) rejects any -// slug that is not [a-z0-9-], so every character class outside that MUST be +// the serve path (serveHandler VALID_SLUG=/^[a-z0-9][a-z0-9_-]*$/) rejects any +// slug that is not [a-z0-9_-], so every character class outside that MUST be // folded to a hyphen here or the /topics/ route 404s (#2099 defect). export function slugifyTopic(value) { return String(value) diff --git a/test/lib/content-store.test.js b/test/lib/content-store.test.js index 48c2d2f97..cf640af1a 100644 --- a/test/lib/content-store.test.js +++ b/test/lib/content-store.test.js @@ -269,7 +269,10 @@ describe('content-store', () => { }); it('serves the styled 404 for a malformed slug rather than raw JSON', async () => { - const res = await project.axios.get('/content/tutorials/Bad_Slug.html', { + // #2587: underscores are now VALID (source tutorial slugs carry them), so a + // genuinely malformed slug must use a char still outside VALID_SLUG — here a + // space. (This path reaches the 404 directly; it isn't a redirect candidate.) + const res = await project.axios.get('/content/tutorials/bad%20slug', { maxRedirects: 0, validateStatus: () => true }); @@ -308,6 +311,53 @@ describe('content-store', () => { const res = await project.axios.get('/content/tutorials/served-tut'); expect(res.data).toBe(htmlV2); }); + + // #2587 (3a): source-repo tutorial folder names legitimately contain + // underscores (e.g. btp-integration-suite-creating_data_type-message_type). + // The write path stores them verbatim, so the read path must accept them too. + // Before the VALID_SLUG fix these 404'd at the serveHandler validation gate + // BEFORE any ContentFiles lookup, despite being present in the ACTIVE snapshot. + describe('underscore slugs (#2587 3a)', () => { + const uHtml = '

Underscore Tutorial

'; + const uMarkdown = '# Underscore Tutorial\n\nStep one.\n'; + const uSlug = 'btp-integration-suite-creating_data_type-message_type'; + + beforeEach(async () => { + await project.axios.post('/content/publish', { + trigger: 'seed-underscore', + files: makePayload({ [uSlug]: uHtml }), + // Supply the markdown source so the /tutorials/.md alternate has + // sourceContent to serve — exercises the VALID_SLUG gate on the md path. + sources: makePayload({ [uSlug]: uMarkdown }) + }, { headers: { Authorization: `Bearer ${API_KEY}` } }); + }); + + it('serves HTML for a slug containing underscores', async () => { + const res = await project.axios.get(`/content/tutorials/${uSlug}`, { + validateStatus: () => true + }); + expect(res.status).toBe(200); + expect(res.data).toBe(uHtml); + expect(res.headers['content-type']).toContain('text/html'); + }); + + it('serves the markdown alternate for an underscore slug', async () => { + const res = await project.axios.get(`/content/tutorials/${uSlug}.md`, { + validateStatus: () => true + }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/markdown'); + }); + + it('301-redirects a mixed-case underscore slug to lowercase', async () => { + const res = await project.axios.get( + '/content/tutorials/BTP-Integration-Suite-Creating_Data_Type-Message_Type', + { maxRedirects: 0, validateStatus: () => true } + ); + expect(res.status).toBe(301); + expect(res.headers['location']).toBe(`/tutorials/${uSlug}`); + }); + }); }); describe('GET /content/hashes', () => { diff --git a/test/slug-utils.test.js b/test/slug-utils.test.js index c7e3312b9..12a7cab58 100644 --- a/test/slug-utils.test.js +++ b/test/slug-utils.test.js @@ -35,10 +35,13 @@ describe('slugify()', () => { expect(out.length).toBe(200); }); - it('produces output matching the content-store VALID_SLUG contract', () => { - const VALID_SLUG = /^[a-z0-9][a-z0-9-]*$/; + it('produces hyphen-only output (narrower than the serve-side VALID_SLUG)', () => { + // slugify() folds every non-[a-z0-9] run to a hyphen, so its output is always + // hyphen-only — a strict subset of serveHandler's VALID_SLUG, which also + // accepts underscores for verbatim source-repo tutorial slugs (#2587). + const SLUGIFY_OUTPUT = /^[a-z0-9][a-z0-9-]*$/; for (const input of ['Test Group', 'Schöne!', ' leading', '___bad___', 'OK 1']) { - expect(slugify(input)).toMatch(VALID_SLUG); + expect(slugify(input)).toMatch(SLUGIFY_OUTPUT); } }); }); diff --git a/test/srv-qa/content-service.test.js b/test/srv-qa/content-service.test.js index 38b61969b..a34ccf21b 100644 --- a/test/srv-qa/content-service.test.js +++ b/test/srv-qa/content-service.test.js @@ -18,8 +18,9 @@ process.env.CONTENT_API_KEY_QA = apiKey; // need to load db/schema.cds to satisfy the lock/log paths inside publishHandler. const project = cds.test('serve', 'srv-qa/search-service.cds', '--in-memory'); -// Test slug must pass the VALID_SLUG regex: /^[a-z0-9][a-z0-9-]*$/ -// (underscores are not allowed — __TEST__qa would return 400) +// Test slug must pass the VALID_SLUG regex: /^[a-z0-9][a-z0-9_-]*$/ +// (first char must be [a-z0-9]; underscores are allowed mid-slug since #2587 — +// e.g. __TEST__qa still returns 400 for its leading underscore + uppercase) const TEST_SLUG = 'test-qa-content'; describe('QA content endpoints', () => { From eb6459d07718c717e1e1a382c5abd0d61d60aaba Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:46:07 -0400 Subject: [PATCH 10/37] feat(#2585): AdminService projections for quarantine events --- srv/admin-service.cds | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 550f47855..52e6cd58f 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -11,6 +11,7 @@ using from '../db/devtoberfest-analytics'; using from '../db/mcp-pats'; using from '../db/tutorial-images'; using from '../db/tutorial-assets'; +using from '../db/tutorial-quarantine'; using from '../app/admin-annotations'; using { external.devtoberfest as external_dtf } from '../db/external/devtoberfest'; @@ -270,6 +271,17 @@ service AdminService { entity TutorialRepositories as projection on ims.TutorialRepositories; entity ImsConfig as projection on ims.ImsConfig; entity StepFailures as projection on ims.StepFailures; + + // #2585 — tutorial quarantine persistence. Raw projections for over-time / + // per-repo reporting; QuarantineEventsCurrent is the live set (events whose + // parent snapshot isCurrent) that the Admin list view binds to. + @readonly entity QuarantineSnapshots as projection on ims.QuarantineSnapshots; + @readonly entity QuarantineEvents as projection on ims.QuarantineEvents; + @readonly @cds.redirection.target: false + entity QuarantineEventsCurrent as + projection on ims.QuarantineEvents + { *, snapshot.runId as runId, snapshot.workflowUrl as workflowUrl, snapshot.createdAt as buildAt } + where snapshot.isCurrent = true; entity NGDSFailedMessages as projection on ims.NGDSFailedMessages; entity DeveloperEnvironmentTabs as projection on ims.DeveloperEnvironmentTabs; @odata.draft.enabled From 0dc99f927b045d4288d48922b34ee48b4064a43f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 09:52:50 -0400 Subject: [PATCH 11/37] feat(#2585): quarantineIngestHandler for POST /content/quarantine-events --- packages/content/content-store.js | 60 +++++++++++++++++++++++++++++++ srv/lib/content-store.js | 1 + 2 files changed, 61 insertions(+) diff --git a/packages/content/content-store.js b/packages/content/content-store.js index e80b3fd6e..aefca9f63 100644 --- a/packages/content/content-store.js +++ b/packages/content/content-store.js @@ -2154,6 +2154,64 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap } } + // --- quarantineIngestHandler: Express handler for /content/quarantine-events --- + // + // Called by scripts/validate-tutorials.ts on a FULL rebuild (#2585). Writes an + // immutable snapshot of the current quarantine set + child events, and flips any + // prior isCurrent snapshot off, so "current quarantine set" = the newest + // isCurrent snapshot's children. Only mode=full posts (a partial run's set would + // wrongly clear out-of-scope slugs). Idempotent per runId: a repeat POST for the + // same runId replaces that run's snapshot. Same auth as /content/publish. + async function quarantineIngestHandler(req, res) { + try { + const { runId, workflowUrl, manifestVersion, buildMode, events } = req.body || {}; + if (buildMode !== 'full') { + return res.status(400).json({ error: "Only buildMode 'full' may post a quarantine snapshot" }); + } + if (!Array.isArray(events)) { + return res.status(400).json({ error: "'events' must be an array" }); + } + const { QuarantineSnapshots } = cds.entities(namespace); + const db = await cds.connect.to('db'); + const result = await db.tx(async (tx) => { + // Idempotency: drop any prior snapshot for this runId (its events cascade via composition). + if (runId) { + const prior = await tx.run(SELECT.from(QuarantineSnapshots).columns('ID').where({ runId: String(runId) })); + if (prior.length) { + await tx.run(DELETE.from(QuarantineSnapshots).where({ ID: prior.map(p => p.ID) })); + } + } + // Flip the existing current snapshot off. + await tx.run(UPDATE(QuarantineSnapshots).set({ isCurrent: false }).where({ isCurrent: true })); + // Insert the new current snapshot + its events via deep insert. + const snapshotId = cds.utils.uuid(); + await tx.run(INSERT.into(QuarantineSnapshots).entries({ + ID: snapshotId, + runId: runId ? String(runId).slice(0, 60) : null, + workflowUrl: workflowUrl ? String(workflowUrl).slice(0, 400) : null, + manifestVersion: manifestVersion ? String(manifestVersion).slice(0, 60) : null, + buildMode: 'full', + isCurrent: true, + eventCount: events.length, + events: events.map(e => ({ + ID: cds.utils.uuid(), + slug: String(e.slug || '').toLowerCase().slice(0, 255), + sourceFile: e.sourceFile ? String(e.sourceFile).slice(0, 255) : null, + sourceRepo: e.sourceRepo ? String(e.sourceRepo).slice(0, 120) : null, + reason: String(e.reason || '').slice(0, 500), + sourceUrl: e.sourceUrl ? String(e.sourceUrl).slice(0, 600) : null, + })), + })); + return { snapshotId, eventCount: events.length }; + }); + LOG.warn(`[content/quarantine-events] recorded snapshot ${result.snapshotId} with ${result.eventCount} events (run ${runId || 'n/a'})`); + return res.status(201).json(result); + } catch (err) { + LOG.error(`[content/quarantine-events] ${err.message}`); + return res.status(500).json({ error: err.message }); + } + } + return { contentAuthMiddleware, publishHandler, @@ -2170,6 +2228,7 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap commitHandler, abortHandler, pipelineLogFailureHandler, + quarantineIngestHandler, pageServeHandler, authorServeHandler, advocateServeHandler @@ -2196,6 +2255,7 @@ export const appendHandler = _defaults.appendHandler; export const commitHandler = _defaults.commitHandler; export const abortHandler = _defaults.abortHandler; export const pipelineLogFailureHandler = _defaults.pipelineLogFailureHandler; +export const quarantineIngestHandler = _defaults.quarantineIngestHandler; export const pageServeHandler = _defaults.pageServeHandler; export const authorServeHandler = _defaults.authorServeHandler; export const advocateServeHandler = _defaults.advocateServeHandler; diff --git a/srv/lib/content-store.js b/srv/lib/content-store.js index 1d56b15a3..c1a4db1b3 100644 --- a/srv/lib/content-store.js +++ b/srv/lib/content-store.js @@ -27,6 +27,7 @@ export const appendHandler = mod.appendHandler; export const commitHandler = mod.commitHandler; export const abortHandler = mod.abortHandler; export const pipelineLogFailureHandler = mod.pipelineLogFailureHandler; +export const quarantineIngestHandler = mod.quarantineIngestHandler; export const pageServeHandler = mod.pageServeHandler; export const authorServeHandler = mod.authorServeHandler; export const advocateServeHandler = mod.advocateServeHandler; From 09baf2751a5b96d01a77014d0aac0912ddb7b8a2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:03:57 -0400 Subject: [PATCH 12/37] feat(#2585): register POST /content/quarantine-events + handler tests --- .../lib/quarantine-ingest-routes.test.js | 92 +++++++++++++++++++ srv/server.js | 5 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 srv/__tests__/lib/quarantine-ingest-routes.test.js diff --git a/srv/__tests__/lib/quarantine-ingest-routes.test.js b/srv/__tests__/lib/quarantine-ingest-routes.test.js new file mode 100644 index 000000000..1c4c7fe39 --- /dev/null +++ b/srv/__tests__/lib/quarantine-ingest-routes.test.js @@ -0,0 +1,92 @@ +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { quarantineIngestHandler } from '../../lib/content-store.js'; + +const NS = 'com.sap.developers.ims'; + +function makeReq(body = {}, headers = {}) { + return { body, headers, get(k) { return this.headers[k.toLowerCase()]; } }; +} +function makeRes() { + const res = { + _status: null, _body: null, _headers: {}, + status(code) { this._status = code; return this; }, + json(body) { this._body = body; return this; }, + setHeader(k, v) { this._headers[k] = v; } + }; + return res; +} + +cds.test('serve', '--project', '.', '--in-memory'); + +describe('quarantine ingest route', () => { + beforeAll(async () => { await cds.connect.to('db'); }); + + beforeEach(async () => { + const { QuarantineSnapshots, QuarantineEvents } = cds.entities(NS); + await DELETE.from(QuarantineEvents); + await DELETE.from(QuarantineSnapshots); + }); + + it('rejects non-full buildMode with 400', async () => { + const res = makeRes(); + await quarantineIngestHandler(makeReq({ buildMode: 'slug', events: [] }), res); + expect(res._status).toBe(400); + }); + + it('rejects non-array events with 400', async () => { + const res = makeRes(); + await quarantineIngestHandler(makeReq({ buildMode: 'full', events: 'nope' }), res); + expect(res._status).toBe(400); + }); + + it('inserts a current snapshot + events and flips prior current off', async () => { + const { QuarantineSnapshots } = cds.entities(NS); + + const res1 = makeRes(); + await quarantineIngestHandler(makeReq({ + runId: 'run-1', workflowUrl: 'https://x/runs/run-1', buildMode: 'full', + events: [{ slug: 'Foo-Bar', sourceFile: 'foo.md', sourceRepo: 'repo-a', reason: 'empty' }] + }), res1); + expect(res1._status).toBe(201); + expect(res1._body.eventCount).toBe(1); + + const res2 = makeRes(); + await quarantineIngestHandler(makeReq({ + runId: 'run-2', buildMode: 'full', + events: [{ slug: 'baz', reason: 'bad stepCount' }] + }), res2); + expect(res2._status).toBe(201); + + const currents = await SELECT.from(QuarantineSnapshots).where({ isCurrent: true }); + expect(currents.length).toBe(1); + expect(currents[0].runId).toBe('run-2'); + }); + + it('lowercases slugs on insert', async () => { + const { QuarantineEvents } = cds.entities(NS); + await quarantineIngestHandler(makeReq({ + runId: 'r', buildMode: 'full', + events: [{ slug: 'MixedCase-Slug', reason: 'x' }] + }), makeRes()); + const rows = await SELECT.from(QuarantineEvents); + expect(rows.map(r => r.slug)).toContain('mixedcase-slug'); + }); + + it('is idempotent per runId (repeat replaces, no double-count)', async () => { + const { QuarantineSnapshots } = cds.entities(NS); + const body = { runId: 'dup', buildMode: 'full', events: [{ slug: 'a', reason: 'x' }] }; + await quarantineIngestHandler(makeReq(body), makeRes()); + await quarantineIngestHandler(makeReq(body), makeRes()); + const snaps = await SELECT.from(QuarantineSnapshots).where({ runId: 'dup' }); + expect(snaps.length).toBe(1); + }); + + it('empty events on full build writes an empty current snapshot (auto-clear)', async () => { + const { QuarantineSnapshots } = cds.entities(NS); + await quarantineIngestHandler(makeReq({ runId: 'clean', buildMode: 'full', events: [] }), makeRes()); + const cur = await SELECT.from(QuarantineSnapshots).where({ isCurrent: true }); + expect(cur.length).toBe(1); + expect(cur[0].eventCount).toBe(0); + }); +}); diff --git a/srv/server.js b/srv/server.js index 6cf626a8e..b87bfe384 100644 --- a/srv/server.js +++ b/srv/server.js @@ -31,7 +31,7 @@ import { decideHandler } from './lib/branch/decide.js'; import { getTagLabelMap } from './lib/tag-label-map.js'; import { myProgressHandler } from './lib/my-progress-handler.js'; import { basicAuthMiddleware } from './lib/tech-user-auth.js'; -import { contentAuthMiddleware, publishHandler, serveHandler, markdownServeHandler, pageServeHandler, authorServeHandler, advocateServeHandler, hashesHandler, sourceHashesHandler, navHandler, rollbackHandler, orphanPurgeHandler, invalidateRenderCache, beginHandler, appendHandler, commitHandler, abortHandler, pipelineLogFailureHandler } from './lib/content-store.js'; +import { contentAuthMiddleware, publishHandler, serveHandler, markdownServeHandler, pageServeHandler, authorServeHandler, advocateServeHandler, hashesHandler, sourceHashesHandler, navHandler, rollbackHandler, orphanPurgeHandler, invalidateRenderCache, beginHandler, appendHandler, commitHandler, abortHandler, pipelineLogFailureHandler, quarantineIngestHandler } from './lib/content-store.js'; import { imageSourceHandler } from './lib/image-source-handler.js'; import { imageIngestHandler } from './lib/image-ingest-handler.js'; import { attachmentSourceHandler } from './lib/attachment-source-handler.js'; @@ -973,6 +973,9 @@ cds.on('bootstrap', (app) => { // going red in an unwatched CI tab. Same auth as /content/publish. app.post('/content/pipeline-log', express.json({ limit: '256kb' }), contentAuthMiddleware, pipelineLogFailureHandler); + // #2585 — CI full-rebuild quarantine snapshot. Same auth as /content/publish. + app.post('/content/quarantine-events', express.json({ limit: '2mb' }), contentAuthMiddleware, quarantineIngestHandler); + // Deploy lifecycle alerts (#deploy-alerts): scripts/deploy-mta.cjs pings this // at start/end/fail of a deploy → ANS. Same bearer auth (CONTENT_API_KEY) as // the other ops endpoints. Body parser is applied inside register(). From 756eb1160391bb8b5ee6a4ddf9695f700c8c72ab Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:09:12 -0400 Subject: [PATCH 13/37] feat(#2585): validate-tutorials posts full-build quarantine snapshot (fail-open) --- scripts/validate-tutorials.ts | 158 +++++++++++++----- ...validate-tutorials-quarantine-post.test.ts | 51 ++++++ 2 files changed, 164 insertions(+), 45 deletions(-) create mode 100644 test/validate-tutorials-quarantine-post.test.ts diff --git a/scripts/validate-tutorials.ts b/scripts/validate-tutorials.ts index 188b7b073..f662c5047 100644 --- a/scripts/validate-tutorials.ts +++ b/scripts/validate-tutorials.ts @@ -113,65 +113,133 @@ export function emptyContentCheck(content: string): string | null { return null } -const files = readdirSync(TUTORIALS_DIR).filter(f => f.endsWith('.md') && !f.startsWith('_')) +/** + * Best-effort POST of the full-rebuild quarantine snapshot to the CAP backend + * (#2585). FAIL-OPEN by contract: a missing config, a rejected fetch, or a + * non-ok response is logged and swallowed — this must NEVER throw or make the + * validator exit non-zero, and must NOT fire from the mta.yaml before-all build + * or local runs (no backend), which is why it no-ops unless report === 'full' + * AND both base + key are present. + */ +export async function postQuarantineSnapshot( + events: Array<{ slug: string; sourceFile: string; sourceRepo?: string; reason: string; sourceUrl?: string }>, + env: { base?: string; key?: string; runId?: string; workflowUrl?: string; report?: string }, +): Promise { + if (env.report !== 'full' || !env.base || !env.key) return + const url = `${env.base.replace(/\/$/, '')}/content/quarantine-events` + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.key}`, + 'Content-Type': 'application/json', + 'x-initiator': env.runId ? `ci/${env.runId}` : 'ci', + }, + body: JSON.stringify({ + runId: env.runId, + workflowUrl: env.workflowUrl, + buildMode: 'full', + events, + }), + }) + if (!resp.ok) { + console.warn(` ⚠ quarantine snapshot POST returned ${resp.status} — continuing (fail-open)`) + } else { + console.log(` → quarantine snapshot posted (${events.length} events)`) + } + } catch (e: any) { + console.warn(` ⚠ quarantine snapshot POST failed: ${e?.message ?? e} — continuing (fail-open)`) + } +} + +async function main() { + const files = readdirSync(TUTORIALS_DIR).filter(f => f.endsWith('.md') && !f.startsWith('_')) -console.log(`Pre-validating ${files.length} tutorials (Hugo frontmatter)...\n`) + console.log(`Pre-validating ${files.length} tutorials (Hugo frontmatter)...\n`) -const quarantined: Array<{ file: string; reason: string }> = [] -const repoBySlug = loadRepoBySlug() + const quarantined: Array<{ file: string; slug: string; sourceRepo?: string; reason: string }> = [] + const repoBySlug = loadRepoBySlug() -for (const file of files) { - const content = readFileSync(join(TUTORIALS_DIR, file), 'utf-8') - let reason: string | null = emptyContentCheck(content) + for (const file of files) { + const content = readFileSync(join(TUTORIALS_DIR, file), 'utf-8') + let reason: string | null = emptyContentCheck(content) + let fm: any - if (!reason) { - try { - const { data: fm } = matter(content) + if (!reason) { + try { + fm = matter(content).data - // Check required frontmatter fields - for (const field of REQUIRED_FIELDS) { - if (fm[field] === undefined || fm[field] === null) { - reason = `Missing required frontmatter field: ${field}` - break + // Check required frontmatter fields + for (const field of REQUIRED_FIELDS) { + if (fm[field] === undefined || fm[field] === null) { + reason = `Missing required frontmatter field: ${field}` + break + } } - } - // Validate time is a positive number - if (!reason && (typeof fm.time !== 'number' || fm.time <= 0)) { - reason = `Invalid 'time' value: ${fm.time}` - } + // Validate time is a positive number + if (!reason && (typeof fm.time !== 'number' || fm.time <= 0)) { + reason = `Invalid 'time' value: ${fm.time}` + } - // Validate stepCount. Normal tutorials require ≥1 step; the - // developer-advocates repo family may publish 0-step tutorials (#2127). - if (!reason) { - reason = stepCountReason(fm.stepCount, repoBySlug[fm.slug]) - } + // Validate stepCount. Normal tutorials require ≥1 step; the + // developer-advocates repo family may publish 0-step tutorials (#2127). + if (!reason) { + reason = stepCountReason(fm.stepCount, repoBySlug[fm.slug]) + } - // Check for unclosed shortcode blocks (Hugo-specific). Helper is exported - // for unit testing — see test/validate-tutorials-shortcode.test.ts. - if (!reason) { - const body = content.replace(/^---[\s\S]*?---\n?/, '') - reason = shortcodeBalanceCheck(body) + // Check for unclosed shortcode blocks (Hugo-specific). Helper is exported + // for unit testing — see test/validate-tutorials-shortcode.test.ts. + if (!reason) { + const body = content.replace(/^---[\s\S]*?---\n?/, '') + reason = shortcodeBalanceCheck(body) + } + } catch (e: any) { + reason = e.message?.slice(0, 200) ?? 'Unknown parse error' } - } catch (e: any) { - reason = e.message?.slice(0, 200) ?? 'Unknown parse error' + } + + if (reason) { + // slug: prefer frontmatter when parseable, else derive from filename + // (empty-content / parse-error paths leave fm undefined). Lowercase + // canonical to match the publish payload and repoBySlug keys. + const slug = String((fm && fm.slug) ? fm.slug : file.replace(/\.md$/, '')).toLowerCase() + quarantined.push({ file, slug, sourceRepo: repoBySlug[slug], reason }) + mkdirSync(QUARANTINE_DIR, { recursive: true }) + renameSync(join(TUTORIALS_DIR, file), join(QUARANTINE_DIR, file)) + console.log(` ✗ ${file}: ${reason}`) } } - if (reason) { - quarantined.push({ file, reason }) - mkdirSync(QUARANTINE_DIR, { recursive: true }) - renameSync(join(TUTORIALS_DIR, file), join(QUARANTINE_DIR, file)) - console.log(` ✗ ${file}: ${reason}`) + if (quarantined.length > 0) { + console.log(`\n${quarantined.length} tutorials quarantined`) + const logPath = join(QUARANTINE_DIR, 'errors.json') + writeFileSync(logPath, JSON.stringify(quarantined, null, 2), 'utf-8') + } else { + console.log('All tutorials passed pre-validation') } -} -if (quarantined.length > 0) { - console.log(`\n${quarantined.length} tutorials quarantined`) - const logPath = join(QUARANTINE_DIR, 'errors.json') - writeFileSync(logPath, JSON.stringify(quarantined, null, 2), 'utf-8') -} else { - console.log('All tutorials passed pre-validation') + console.log(`${files.length - quarantined.length} tutorials ready for build`) + + // #2585 — best-effort snapshot post. No-ops unless this is a full rebuild with + // a reachable backend (guarded inside postQuarantineSnapshot). An empty + // quarantine set on a full build still posts → writes an empty current + // snapshot → the Admin view auto-clears. + await postQuarantineSnapshot( + quarantined.map(q => ({ slug: q.slug, sourceFile: q.file, sourceRepo: q.sourceRepo, reason: q.reason })), + { + base: process.env.CAP_BASE_URL, + key: process.env.CONTENT_API_KEY, + runId: process.env.RUN_ID, + workflowUrl: process.env.WORKFLOW_URL, + report: process.env.QUARANTINE_REPORT, + }, + ) } -console.log(`${files.length - quarantined.length} tutorials ready for build`) +// Run only as the script entrypoint, not when imported (e.g. by unit tests), +// so importing `postQuarantineSnapshot` doesn't execute the validation + file +// moves as an import side effect. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + await main() +} diff --git a/test/validate-tutorials-quarantine-post.test.ts b/test/validate-tutorials-quarantine-post.test.ts new file mode 100644 index 000000000..aa54ff121 --- /dev/null +++ b/test/validate-tutorials-quarantine-post.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { postQuarantineSnapshot } from '../scripts/validate-tutorials' + +const events = [{ slug: 'a', sourceFile: 'a.md', sourceRepo: 'r', reason: 'x' }] + +afterEach(() => { vi.restoreAllMocks() }) + +describe('postQuarantineSnapshot (fail-open guard)', () => { + it('no-ops when report !== full', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await postQuarantineSnapshot(events, { base: 'http://x', key: 'k', report: '' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('no-ops when base or key missing', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await postQuarantineSnapshot(events, { base: '', key: 'k', report: 'full' }) + await postQuarantineSnapshot(events, { base: 'http://x', key: '', report: 'full' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('posts with Bearer auth when fully configured', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ snapshotId: 's', eventCount: 1 }), { status: 201 }) as any + ) + await postQuarantineSnapshot(events, { + base: 'http://x/', key: 'k', runId: '42', workflowUrl: 'http://x/runs/42', report: 'full' + }) + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('http://x/content/quarantine-events') + expect((init as any).headers.Authorization).toBe('Bearer k') + const body = JSON.parse((init as any).body) + expect(body.buildMode).toBe('full') + expect(body.events).toHaveLength(1) + }) + + it('never throws when fetch rejects (fail-open)', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) + await expect( + postQuarantineSnapshot(events, { base: 'http://x', key: 'k', report: 'full' }) + ).resolves.toBeUndefined() + }) + + it('never throws when server returns non-ok (fail-open)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 500 }) as any) + await expect( + postQuarantineSnapshot(events, { base: 'http://x', key: 'k', report: 'full' }) + ).resolves.toBeUndefined() + }) +}) From 643f3fe06eceb91b6aa09ededfc97979ce6fad1c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:23:00 -0400 Subject: [PATCH 14/37] feat(#2585): Admin UI list view for current quarantined tutorials --- app/admin-annotations.cds | 32 ++++++++ .../quarantine-events/webapp/Component.js | 4 + .../webapp/i18n/i18n.properties | 2 + .../quarantine-events/webapp/manifest.json | 82 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 app/admin/quarantine-events/webapp/Component.js create mode 100644 app/admin/quarantine-events/webapp/i18n/i18n.properties create mode 100644 app/admin/quarantine-events/webapp/manifest.json diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 5a283a3ce..5e3e2b396 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -4699,3 +4699,35 @@ annotate AdminService.ChannelTopicMap with @UI: { { Value: authoringStatus } ]} }; + +// --- QuarantineEventsCurrent (read-only, filterable; #2585 current quarantine set) --- +annotate AdminService.QuarantineEventsCurrent with { + slug @Common.Label: 'Slug'; + reason @Common.Label: 'Reason'; + sourceRepo @Common.Label: 'Source Repo'; + sourceFile @Common.Label: 'Source File'; + sourceUrl @Common.Label: 'Source URL'; + buildAt @Common.Label: 'Last Seen Build'; + runId @Common.Label: 'Run'; +}; + +annotate AdminService.QuarantineEventsCurrent with @( + UI: { + HeaderInfo: { + TypeName: 'Quarantined Tutorial', TypeNamePlural: 'Quarantined Tutorials', + Title: { Value: slug } + }, + SelectionFields: [ sourceRepo, buildAt ], + LineItem: [ + { Value: slug }, + { Value: reason }, + { Value: sourceRepo }, + { Value: sourceFile }, + { Value: buildAt }, + { $Type: 'UI.DataFieldWithUrl', Value: sourceUrl, Url: sourceUrl, Label: 'Source' } + ] + }, + Capabilities.DeleteRestrictions.Deletable: false, + Capabilities.InsertRestrictions.Insertable: false, + Capabilities.UpdateRestrictions.Updatable: false +); diff --git a/app/admin/quarantine-events/webapp/Component.js b/app/admin/quarantine-events/webapp/Component.js new file mode 100644 index 000000000..c809349ac --- /dev/null +++ b/app/admin/quarantine-events/webapp/Component.js @@ -0,0 +1,4 @@ +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.quarantineEvents.Component", { metadata: { manifest: "json" } }); +}); diff --git a/app/admin/quarantine-events/webapp/i18n/i18n.properties b/app/admin/quarantine-events/webapp/i18n/i18n.properties new file mode 100644 index 000000000..3fa916bde --- /dev/null +++ b/app/admin/quarantine-events/webapp/i18n/i18n.properties @@ -0,0 +1,2 @@ +appTitle=Quarantined Tutorials +appDescription=Tutorials dropped from the active content snapshot by pre-publish validation diff --git a/app/admin/quarantine-events/webapp/manifest.json b/app/admin/quarantine-events/webapp/manifest.json new file mode 100644 index 000000000..999082f49 --- /dev/null +++ b/app/admin/quarantine-events/webapp/manifest.json @@ -0,0 +1,82 @@ +{ + "_version": "1.65.0", + "sap.app": { + "id": "sap.tutorials.admin.quarantineEvents", + "type": "application", + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { "version": "0.0.1" }, + "i18n": "i18n/i18n.properties", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "QuarantineEventsCurrent-manage": { + "semanticObject": "QuarantineEventsCurrent", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.136.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "": { + "dataSource": "mainService", + "preload": true, + "settings": { + "synchronizationMode": "None", + "operationMode": "Server", + "autoExpandSelect": true, + "earlyRequests": true + } + }, + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { "bundleName": "sap.tutorials.admin.quarantineEvents.i18n.i18n" } + } + }, + "routing": { + "routes": [ + { "name": "QuarantineEventsCurrentList", "pattern": ":?query:", "target": "QuarantineEventsCurrentList" }, + { "name": "QuarantineEventsCurrentObject", "pattern": "QuarantineEventsCurrent({key}):?query:", "target": "QuarantineEventsCurrentObject" } + ], + "targets": { + "QuarantineEventsCurrentList": { + "type": "Component", + "id": "QuarantineEventsCurrentList", + "name": "sap.fe.templates.ListReport", + "options": { + "settings": { + "contextPath": "/QuarantineEventsCurrent", + "initialLoad": "Enabled", + "navigation": { + "QuarantineEventsCurrent": { "detail": { "route": "QuarantineEventsCurrentObject" } } + } + } + } + }, + "QuarantineEventsCurrentObject": { + "type": "Component", + "id": "QuarantineEventsCurrentObject", + "name": "sap.fe.templates.ObjectPage", + "options": { + "settings": { + "contextPath": "/QuarantineEventsCurrent" + } + } + } + } + } + } +} From eedef26b6561bc82e4405520f7daa492f70cb7dd Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:32:13 -0400 Subject: [PATCH 15/37] feat(#2585): wire quarantine-snapshot env into rebuild-content validate step --- .github/workflows/rebuild-content.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index ef8f83fd9..887a5e191 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -630,6 +630,15 @@ jobs: - name: Validate tutorials if: ${{ steps.mode.outputs.effective_mode != 'catalog-only' }} + env: + # [#2585] Persist the full-rebuild quarantine snapshot to HANA. The + # validator is fail-open and no-ops unless QUARANTINE_REPORT=full AND + # both CAP_BASE_URL + CONTENT_API_KEY are set — so only a full build posts. + CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} + CONTENT_API_KEY: ${{ secrets.CONTENT_API_KEY }} + RUN_ID: ${{ github.run_id }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + QUARANTINE_REPORT: ${{ steps.mode.outputs.effective_mode == 'full' && 'full' || '' }} run: npm run validate-tutorials # [#1659 Phase C.3] Seed the island name→hash manifest from the DEPLOYED From 348146910602b68e1757d91cf5b31afb68e938ea Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:34:19 -0400 Subject: [PATCH 16/37] =?UTF-8?q?docs(a2a):=20design=20spec=20for=20XSUAA?= =?UTF-8?q?=E2=86=92IAS=20auth=20migration=20(#2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-10-04-a2a-ias-auth-migration-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md diff --git a/docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md b/docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md new file mode 100644 index 000000000..c07915e71 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md @@ -0,0 +1,152 @@ +# A2A Agent Auth Migration: XSUAA client-credentials → SAP IAS + +**Issue:** [#2593](https://github.com/sap-tutorials/tutorials-ims/issues/2593) +**Date:** 2026-10-04 +**Status:** Design approved — ready for implementation plan +**Branch:** `feat/a2a-ias-auth-2593` (worktree: `.claude/worktrees/a2a-ias-auth`) + +## Problem + +The MCP signed-in tier (`/mcp-auth/*`) was migrated to SAP IAS (OAuth 2.1 +auth-code + PKCE, public client) in PR #2592. The A2A agent (`POST /a2a`) was +intentionally left on the **old XSUAA client-credentials** flow — confirmed +still live on prod via its agent-card (`securitySchemes.xsuaa.flows.clientCredentials`, +scope `Tutorial.MCP`). This spec migrates A2A's token source to IAS. + +### Why "mirror MCP exactly" does not apply + +A2A is **machine-to-machine** (`POST /a2a`, no browser). The MCP migration +solved a *user* auth-code + PKCE redirect flow, which A2A has no equivalent of. +The honest IAS fit for M2M is **OAuth2 client-credentials** (SAP's documented +technical-user model) — a like-for-like swap of the token *source* (XSUAA → IAS) +while keeping the client-credentials *grant*. PKCE does not enter the A2A path. + +## Decisions (locked) + +| Decision | Choice | Rationale | +|---|---|---| +| Auth model | **IAS client-credentials (secret)** | Lowest-risk, true like-for-like with what Central Joule consumes today; unblocks the XSUAA→IAS cutover. mTLS/X509 (secretless) is a later config swap. | +| IAS tenant | **`atxgsg7zi`** (`XSUAA_MCP_URL`) | Matches current MCP prod; keeps A2A and MCP on one tenant. (Issue noted a prior broken-federation note on `atxgsg7zi` resolved by switching to `alzmza7li`; user confirmed `atxgsg7zi`.) | +| Validation location | **In-process in the main `srv`** (A1) | Faithful mirror of how `srv-mcp` validates; keeps A2A in the main MTA. | +| Hybrid fallback | **`xsuaa: true`** on the IAS auth kind | Non-breaking, reversible cutover: XSUAA tokens keep working during transition. | +| `tokenUrl` source | **Derive from IAS issuer** (`/oauth2/token`) | One source of truth, cannot drift; `ChatSettings.a2aTokenUrl` DB column becomes an optional override. | + +## Current-state wiring (confirmed) + +- **A2A route (approuter):** `approuter/xs-app.json:584-591` — `^/a2a/?$` → + `srv-api`, `authenticationType:"xsuaa"`, `scope:"$XSAPPNAME.Tutorial.MCP"`. +- **A2A handler (main MTA):** `srv/server.js:1165` reserves `POST /a2a`; + `srv/server.js:2233-2246` binds `makeA2aRouter()` through CAP `contextMw` + + `authMw`. Anonymous reject at `srv/lib/a2a/rpc-router.js:43-46` (JSON-RPC + `-32001` / HTTP 401), trusting `cds.context.user` from CAP auth. +- **Agent card builder:** `srv/lib/a2a/agent-card.js:32-52` (pure). Lines 44-47 + hardcode `securitySchemes.xsuaa` clientCredentials + `security:[{xsuaa:['Tutorial.MCP']}]`. + Injected at `srv/server.js:1103-1111` via `resolveA2aSettings()` → + `buildAgentCard({baseUrl, tokenUrl, enabled})`. +- **A2A config:** DB-driven, no env. `srv/lib/runtime-config/a2a-settings.js` → + `ChatSettings` singleton (`a2aEnabled`/`a2aPublicBaseUrl`/`a2aTokenUrl`), + `SETTINGS_ID = 00000000-0000-0000-0000-00000000c8a7`. +- **Main srv auth today:** NO `cds.requires.auth` kind — gated purely by the + approuter XSUAA route. No in-process JWKS/issuer validator exists in `srv/`. +- **MCP IAS reference (the template):** `srv-mcp/package.json:28-31` + (`auth:{kind:'ias',xsuaa:true}`, `@sap/xssec@^4.13.1`); approuter discovery + `approuter/lib/well-known-oauth.js` (IAS branch keyed on `MCP_ISSUER_KIND=ias`, + issuer from `XSUAA_MCP_URL`); IAS client `tutorials-identity` bound in + `mta-mcp.yaml:74,84-100` (adopted `existing-service`, public/secretless). + +## Target architecture (A1) + +```text +A2A consumer (Joule / trusted BTP) + → client-credentials exchange against IAS /oauth2/token (client-id + secret) + → POST /a2a with Authorization: Bearer + → approuter ^/a2a/?$ authenticationType:"none" → srv-api destination + → main srv CAP auth {kind:'ias', xsuaa:true} validates IAS bearer (xssec) + → cds.context.user populated → rpc-router (anonymous reject unchanged) +``` + +The approuter stops minting the token (`xsuaa`→`none`, token-as-credential, +matching `/mcp-auth`). CAP's xssec IAS strategy validates in-process. `xsuaa:true` +keeps XSUAA bearers accepted during cutover. + +## Changes + +### 1. Config / binding (the real migration) + +- **`srv` CAP config** — add `cds.requires.auth = { kind: 'ias', xsuaa: true }` + (mirror `srv-mcp/package.json:28-31`). `@sap/xssec@^4.13.1` is **already** a + main-srv dependency (root `package.json`), so no new dep is needed. The main + srv currently declares **no** `cds.requires.auth` kind — this adds the first. +- **`mta.yaml` source + `.deploy/mta.yaml`** — bind `tutorials-identity` (existing + IAS client, `existing-service`) to `srv-api` alongside `tutorials-xsuaa`. + Confirm `srv-api` already carries `XSUAA_MCP_URL` / `MCP_ISSUER_KIND` or that + the IAS issuer is otherwise resolvable in the main srv at runtime. +- **`approuter/xs-app.json:584-591`** — `/a2a` route `authenticationType:"xsuaa"` + → `"none"`; remove the `scope` line. Destination stays `srv-api`. +- **IAS tenant** — stays `atxgsg7zi` via existing `XSUAA_MCP_URL`; no new env var. + +> **Blast-radius note:** adding an `auth` kind to the main `srv` affects **every** +> service in the main MTA, not just `/a2a`. `xsuaa:true` keeps XSUAA tokens valid, +> so XSUAA-gated routes should be unaffected — but this MUST be verified by the +> full unit suite plus smoke against other main-MTA surfaces (admin UI, content, +> `/mcp-admin`) before merge. This is the single highest-risk change in the spec. + +### 2. Agent card (`srv/lib/a2a/agent-card.js:44-47`) + +- Replace `securitySchemes.xsuaa` with an IAS `clientCredentials` scheme keyed + `ias`, `tokenUrl` → IAS `/oauth2/token`. Update `security` to + `[{ ias: [...] }]` with the appropriate IAS scope(s). +- Keep the builder **pure** — `tokenUrl` continues to be injected by the caller. + +### 3. tokenUrl resolution (`srv/server.js:1103-1111` + `a2a-settings.js`) + +- Derive `tokenUrl = /oauth2/token` from the resolved IAS issuer + (same source `well-known-oauth.js:resolveIssuer()` uses — `XSUAA_MCP_URL`). +- `ChatSettings.a2aTokenUrl`, if non-empty, overrides the derived value (admin + escape hatch). Document this precedence in `a2a-settings.js`. + +### 4. Docs (3 files + 1 new row) + +- **`srv/mcp/a2a-instructions.md`** (lines 17-19, 67) — XSUAA → IAS + client-credentials; "OAuth Token URL" → IAS token endpoint. +- **`docs/end-users/a2a-quickstart.md`** (16-17, 53, 63-105, 177) — rewrite the + token-exchange walkthrough for IAS client-credentials (client-id/secret against + IAS `/oauth2/token`). Keep it M2M — no PKCE/interactive path. +- **`hugo/content/api-docs/_index.md`** (139-182, esp. 180) — A2A "### Auth" + section → IAS. Mirror the IAS phrasing already used for `/mcp-auth`. +- **`hugo/data/api_endpoints.yaml`** — **add** an `/a2a` row (none exists today); + mirror the `/mcp-auth/*` row shape, `auth` → IAS client-credentials. Then + `node scripts/check-api-docs-drift.cjs` MUST pass. + +## Testing + +- **Unit (fast, in-memory):** `agent-card.js` builder test asserting the IAS + scheme shape (`securitySchemes.ias`, `clientCredentials`, derived `tokenUrl`), + and the tokenUrl-derivation + DB-override precedence in `a2a-settings.js`. +- **Drift:** `node scripts/check-api-docs-drift.cjs` → OK (row count +1). +- **Hybrid/smoke** (`npm run test:hybrid`, `test:smoke`, `test:e2e` as applicable): + IAS token accepted on `/a2a`; XSUAA token still accepted (hybrid); + anonymous still rejected `-32001`/401 with `WWW-Authenticate: Bearer`. +- **Non-interactive IAS validation against prod tenant** — as #2592 did for the + MCP authorize endpoint: confirm the IAS `/oauth2/token` client-credentials + exchange succeeds for the A2A client on `atxgsg7zi` (no `invalid_client`). +- **Line endings:** all touched files remain LF (Windows worktree — verify). + +## Coordination / rollout + +- **Central Joule & trusted-BTP consumers** — the token *source* changes + (XSUAA → IAS `atxgsg7zi`). `xsuaa:true` hybrid means their existing XSUAA + tokens keep working through the transition, but they must be told to re-point + at the IAS token endpoint before XSUAA is removed. **Flag this in the PR** — + do not remove XSUAA fallback until consumers confirm cutover. +- **Rollback:** revert the approuter route to `xsuaa` + restore the XSUAA + security scheme in the card; the hybrid `auth` kind can stay (it accepts both). + +## Out of scope + +- Option B (IAS mTLS/X509 secretless) and Option C (forwarded user token / + on-behalf-of). B is a later config swap enabled by this structure. +- Central Joule's own token-source change (coordinated, not implemented here). +- Homepage `/` flip and any non-A2A auth surface. +- Removing the XSUAA fallback (`xsuaa:false`) — a separate follow-up after + consumer cutover is confirmed. From 4308cb798017f627bcc6c1ad952839ec182c3b2b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:41:10 -0400 Subject: [PATCH 17/37] fix(test): evict shared query-embedding cache in semantic-search tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cds-caching 'memory' store is a process singleton that survives Vitest's per-file isolation (and cds.disconnect()+delete cds.db). A stale semq: entry written by an earlier file in the same worker — or a sibling subsystem test (youtube-fetcher/khoros-cache/homepage-rss) that calls full-store .clear() or mutates the global caching namespace and never restores it — poisons the handler test's 'cap' query embedding, dropping every row and failing deterministically only under CI's file ordering. _resetForTest() previously only nulled the memoized connection promise; it now also awaits cache().clear() to evict the shared store. Both semantic-search test files clear in beforeAll (defend against inbound leaks) and afterAll (don't leak outbound), and await the now-async reset. Verified: poison-repro recovers from [] to ['handler-tut'] with the clear; the 9 caching-singleton-touching unit files + both sem files pass serially (74 tests), including the worst-case interleave of a full-store .clear() sibling run between the two sem files. --- srv/lib/semantic-search.js | 18 ++++++++++++++++-- test/unit/semantic-search-handler.test.js | 9 ++++++++- test/unit/semantic-search.test.js | 3 ++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/srv/lib/semantic-search.js b/srv/lib/semantic-search.js index 942efaa0e..8d061bce7 100644 --- a/srv/lib/semantic-search.js +++ b/srv/lib/semantic-search.js @@ -59,8 +59,22 @@ const _testState = (globalThis[Symbol.for('ims.semanticSearch.testState')] ??= { }); /** Inject a fake embed() for unit tests (returns [Float32Array]). */ export function _setTestEmbedClient(fn) { _testState.embedFn = fn; } -/** Reset module state — test-only. */ -export function _resetForTest() { _testState.embedFn = null; _testState.cachePromise = undefined; } +/** + * Reset module state — test-only. Drops the fake embed and the memoized caching + * connection, and evicts the shared query-embedding store. The cds-caching + * `memory` store is a PROCESS singleton that survives Vitest's per-file isolation + * (and `cds.disconnect()` + `delete cds.db`), so without the `.clear()` a stale + * `semq:` entry written by an earlier file in the same worker — or a sibling + * subsystem test that mutated the global caching namespace — can poison a later + * query's embedding and silently drop every row. Awaitable so tests can clear + * before relying on a fresh embed. Fail-open: never throws. + */ +export async function _resetForTest() { + _testState.embedFn = null; + try { const c = await cache(); if (c) await c.clear(); } + catch (err) { LOG.warn('query-embedding cache clear failed', err.message); } + _testState.cachePromise = undefined; +} function embedFn() { return _testState.embedFn || defaultEmbed; } // ---- Query-embedding cache ------------------------------------------------ diff --git a/test/unit/semantic-search-handler.test.js b/test/unit/semantic-search-handler.test.js index abdddbe22..86a7a774d 100644 --- a/test/unit/semantic-search-handler.test.js +++ b/test/unit/semantic-search-handler.test.js @@ -11,7 +11,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'node:path'; import cds from '@sap/cds'; -import { _setTestEmbedClient } from '../../srv/lib/semantic-search.js'; +import { _setTestEmbedClient, _resetForTest as _resetSemModule } from '../../srv/lib/semantic-search.js'; import { _resetForTest as _resetSvc } from '../../srv/search-service.js'; import { _resetCacheForTests as _resetSearchSettings } from '../../srv/lib/runtime-config/search-settings.js'; @@ -41,6 +41,12 @@ beforeAll(async () => { SearchService = await cds.serve('SearchService').from('./srv/search-service'); + // The cds-caching `memory` store is a process singleton that survives Vitest's + // per-file isolation; a sibling file in the same worker may have left a stale + // `semq:` entry (or mutated the global caching namespace). Evict it so this + // file's queries embed fresh against the fake client below. + await _resetSemModule(); + _setTestEmbedClient(async (inputs) => inputs.map(() => unitVec(0))); const { Tutorials, TutorialEmbedding } = cds.entities(NS); @@ -53,6 +59,7 @@ beforeAll(async () => { afterAll(async () => { _resetSvc(); + await _resetSemModule(); // evict this file's semq: entries so they can't poison siblings await cds.disconnect(); delete cds.db; delete cds.model; diff --git a/test/unit/semantic-search.test.js b/test/unit/semantic-search.test.js index c043abfe7..eaa8eb2c4 100644 --- a/test/unit/semantic-search.test.js +++ b/test/unit/semantic-search.test.js @@ -123,10 +123,11 @@ describe('semanticSearch (core module)', () => { beforeAll(async () => { await cds.deploy([path.join(process.cwd(), 'db')]).to('sqlite::memory:'); await seedCorpora(); + await _resetModule(); // evict any semq: entries leaked by a sibling file in this worker }); afterAll(async () => { - _resetModule(); + await _resetModule(); await cds.disconnect(); delete cds.db; delete cds.model; From 403b7cb440c531e04646bf6eaaa9a55039aa3011 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:52:57 -0400 Subject: [PATCH 18/37] feat(a2a): derive IAS tokenUrl from issuer, DB column override (#2593) --- srv/lib/runtime-config/a2a-settings.js | 8 +++++++- test/unit/a2a/a2a-settings.test.js | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/srv/lib/runtime-config/a2a-settings.js b/srv/lib/runtime-config/a2a-settings.js index d24f2dc84..7bcef802d 100644 --- a/srv/lib/runtime-config/a2a-settings.js +++ b/srv/lib/runtime-config/a2a-settings.js @@ -12,6 +12,11 @@ let _cached = null; const DEFAULTS = { enabled: true, publicBaseUrl: '', tokenUrl: '' }; +export function deriveIasTokenUrl(issuerBase) { + if (!issuerBase || typeof issuerBase !== 'string') return ''; + return `${issuerBase.replace(/\/+$/, '')}/oauth2/token`; +} + async function readRow() { try { const { ChatSettings } = cds.entities('com.sap.developers.ims'); @@ -47,10 +52,11 @@ export async function resolveA2aSettings() { const baseRaw = pick(row, 'a2aPublicBaseUrl', 'A2APUBLICBASEURL'); const tokenRaw = pick(row, 'a2aTokenUrl', 'A2ATOKENURL'); + const derivedTokenUrl = deriveIasTokenUrl(process.env.XSUAA_MCP_URL); _cached = { enabled: enabledRaw == null ? DEFAULTS.enabled : !!enabledRaw, publicBaseUrl: baseRaw || DEFAULTS.publicBaseUrl, - tokenUrl: tokenRaw || DEFAULTS.tokenUrl, + tokenUrl: tokenRaw || derivedTokenUrl || DEFAULTS.tokenUrl, }; _cachedAt = now; return _cached; diff --git a/test/unit/a2a/a2a-settings.test.js b/test/unit/a2a/a2a-settings.test.js index 54c42842f..110390229 100644 --- a/test/unit/a2a/a2a-settings.test.js +++ b/test/unit/a2a/a2a-settings.test.js @@ -12,7 +12,7 @@ vi.mock('@sap/cds', () => ({ // SELECT.one.from(ChatSettings) → resolve `row` globalThis.SELECT = { one: { from: () => Promise.resolve(row) } }; -import { resolveA2aSettings, _resetA2aSettingsCache } from '../../../srv/lib/runtime-config/a2a-settings.js'; +import { deriveIasTokenUrl, resolveA2aSettings, _resetA2aSettingsCache } from '../../../srv/lib/runtime-config/a2a-settings.js'; describe('resolveA2aSettings', () => { beforeEach(() => { row = null; _resetA2aSettingsCache(); vi.clearAllMocks(); }); @@ -75,3 +75,18 @@ describe('resolveA2aSettings', () => { } }); }); + +describe('deriveIasTokenUrl', () => { + it('appends /oauth2/token to the IAS issuer base', () => { + expect(deriveIasTokenUrl('https://atxgsg7zi.accounts.ondemand.com')) + .toBe('https://atxgsg7zi.accounts.ondemand.com/oauth2/token'); + }); + it('trims a trailing slash on the issuer base', () => { + expect(deriveIasTokenUrl('https://atxgsg7zi.accounts.ondemand.com/')) + .toBe('https://atxgsg7zi.accounts.ondemand.com/oauth2/token'); + }); + it('returns empty string for a blank issuer', () => { + expect(deriveIasTokenUrl('')).toBe(''); + expect(deriveIasTokenUrl(undefined)).toBe(''); + }); +}); From 78b01aa8484a4bfda3879a0e173cdf1eb5624c1d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:06:08 -0400 Subject: [PATCH 19/37] feat(a2a): advertise IAS client-credentials in agent card (#2593) --- srv/lib/a2a/agent-card.js | 4 ++-- test/unit/a2a/agent-card.test.js | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/srv/lib/a2a/agent-card.js b/srv/lib/a2a/agent-card.js index 368503166..1c4892bb2 100644 --- a/srv/lib/a2a/agent-card.js +++ b/srv/lib/a2a/agent-card.js @@ -42,9 +42,9 @@ export function buildAgentCard({ baseUrl, tokenUrl, enabled = true }) { defaultInputModes: ['text/plain'], defaultOutputModes: ['text/plain', 'application/json'], securitySchemes: { - xsuaa: { type: 'oauth2', flows: { clientCredentials: { tokenUrl: tokenUrl || '', scopes: { 'Tutorial.MCP': 'MCP/A2A protocol access — authenticated tutorial reads/writes' } } } }, + ias: { type: 'oauth2', flows: { clientCredentials: { tokenUrl: tokenUrl || '', scopes: { openid: 'SAP IAS technical-user access — authenticated tutorial reads/writes' } } } }, }, - security: [{ xsuaa: ['Tutorial.MCP'] }], + security: [{ ias: ['openid'] }], skills: SKILLS, documentationUrl: `${baseUrl}/.well-known/a2a-instructions.md`, metadata: { available: enabled }, diff --git a/test/unit/a2a/agent-card.test.js b/test/unit/a2a/agent-card.test.js index 37c2c31e9..5f67776db 100644 --- a/test/unit/a2a/agent-card.test.js +++ b/test/unit/a2a/agent-card.test.js @@ -22,9 +22,12 @@ describe('buildAgentCard', () => { } }); - it('injects tokenUrl into the xsuaa security scheme', () => { - expect(card.securitySchemes.xsuaa.flows.clientCredentials.tokenUrl).toBe('https://uaa.example/oauth/token'); - expect(card.security).toEqual([{ xsuaa: ['Tutorial.MCP'] }]); + it('injects tokenUrl into the ias client-credentials scheme', () => { + expect(card.securitySchemes.ias.type).toBe('oauth2'); + expect(card.securitySchemes.ias.flows.clientCredentials.tokenUrl) + .toBe('https://uaa.example/oauth/token'); + expect(card.securitySchemes.xsuaa).toBeUndefined(); + expect(card.security).toEqual([{ ias: ['openid'] }]); }); it('signals unavailability when disabled but still returns a card', () => { From 231f25b747008d95cabb4d75323c65aca6a08d69 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:23:10 -0400 Subject: [PATCH 20/37] feat(auth): surface login-provider profile picture (#2628) Social logins can now contribute a profile picture via the OIDC `picture` claim, with the SAP Community (Khoros) avatar taking precedence and the login-provider picture as the fallback. - github-oidc-shim: fetch avatar_url and mint it as the `picture` claim (advertised in discovery `claims_supported`); re-stamped each login since provider URLs rotate. - Users.pictureUrl column + refreshUserPicture() with always-overwrite (not blanks-only) semantics; a token with no picture claim never clears an existing value. Wired into provisionDbUser (INSERT + existing-row). - /auth/user exposes pictureUrl (live claim preferred over persisted row) and a server-resolved avatarUrl (Khoros wins -> provider -> null). - header.html renders the resolved avatar with broken-image -> initials fallback; cache carries the new fields. - CSP img-src: add Google / LinkedIn / Hugging Face avatar hosts (GitHub hosts already allowed). The general path (Google/LinkedIn/HF) also needs IAS attribute mapping to pass the `picture` claim through -- tenant config, done separately. Tests: shim (17), refreshUserPicture (16), /auth/user (9) all green; db/schema.cds compiles clean. --- .cdsrc.json | 160 +++++++++++++++++++++++---- approuter/lib/github-oidc-shim.js | 4 +- approuter/xs-app.json | 2 +- db/schema.cds | 6 + hugo/layouts/partials/header.html | 20 +++- packages/core/resolve-db-user.js | 39 +++++++ srv/lib/resolve-db-user.js | 1 + srv/server.js | 13 ++- test/backfill-user-profile.test.js | 69 +++++++++++- test/unit/auth-user-endpoint.test.js | 26 +++++ test/unit/github-oidc-shim.test.js | 16 +++ 11 files changed, 325 insertions(+), 31 deletions(-) diff --git a/.cdsrc.json b/.cdsrc.json index f85aeadcd..676ae84aa 100644 --- a/.cdsrc.json +++ b/.cdsrc.json @@ -7,13 +7,74 @@ "build": { "target": "gen", "tasks": [ - { "for": "hana", "src": "db", "dest": "db" }, - { "for": "nodejs", "src": "srv", "dest": "srv", "options": { "model": ["srv", "db", "db/hana", "app", "@cap-js/data-inspector", "@cap-js/ai/srv/AICoreService", "cds-caching/db/cache-store", "cds-caching/db/statistics", "@cap-js/attachments/srv/malware-scanner/malwareScanner-mocked"] } }, - { "for": "hana", "src": "db-qa", "dest": "db-qa", "options": { "model": ["db-qa"] } }, - { "for": "nodejs", "src": "srv-qa", "dest": "srv-qa", "options": { "model": ["srv-qa", "db-qa"] } }, - { "for": "nodejs", "src": "srv-mcp", "dest": "srv-mcp", "options": { "model": ["srv-mcp", "db"] } }, - { "for": "cds-caching" }, - { "for": "hana", "src": "db", "dest": "db", "options": { "model": ["cds-caching/db/statistics"] } } + { + "for": "hana", + "src": "db", + "dest": "db" + }, + { + "for": "nodejs", + "src": "srv", + "dest": "srv", + "options": { + "model": [ + "srv", + "db", + "db/hana", + "app", + "@cap-js/data-inspector", + "@cap-js/ai/srv/AICoreService", + "cds-caching/db/cache-store", + "cds-caching/db/statistics", + "@cap-js/attachments/srv/malware-scanner/malwareScanner-mocked" + ] + } + }, + { + "for": "hana", + "src": "db-qa", + "dest": "db-qa", + "options": { + "model": [ + "db-qa" + ] + } + }, + { + "for": "nodejs", + "src": "srv-qa", + "dest": "srv-qa", + "options": { + "model": [ + "srv-qa", + "db-qa" + ] + } + }, + { + "for": "nodejs", + "src": "srv-mcp", + "dest": "srv-mcp", + "options": { + "model": [ + "srv-mcp", + "db" + ] + } + }, + { + "for": "cds-caching" + }, + { + "for": "hana", + "src": "db", + "dest": "db", + "options": { + "model": [ + "cds-caching/db/statistics" + ] + } + } ] }, "odata": { @@ -22,12 +83,22 @@ "requires": { "auth": { "kind": "mocked", - "[hybrid]": { "kind": "xsuaa", "restrict_all_services": false }, - "[production]": { "kind": "xsuaa", "restrict_all_services": false }, + "[hybrid]": { + "kind": "xsuaa", + "restrict_all_services": false + }, + "[production]": { + "kind": "xsuaa", + "restrict_all_services": false + }, "users": { "developer": { "password": "developer", - "roles": ["DeveloperApp", "Tutorial.Author", "authenticated-user"], + "roles": [ + "DeveloperApp", + "Tutorial.Author", + "authenticated-user" + ], "attr": { "email": "dev@test.com", "given_name": "Test", @@ -36,7 +107,11 @@ }, "P0009999001": { "password": "P0009999001", - "roles": ["DeveloperApp", "Tutorial.Author", "authenticated-user"], + "roles": [ + "DeveloperApp", + "Tutorial.Author", + "authenticated-user" + ], "attr": { "email": "canonical@test.com", "given_name": "Canonical", @@ -45,34 +120,77 @@ }, "admin": { "password": "admin", - "roles": ["Admin", "KnowledgeGraph.Admin", "DeveloperApp", "DisplayApp", "ContentAuthor", "ConsolidationScope", "Tutorial.Author", "authenticated-user"] + "roles": [ + "Admin", + "KnowledgeGraph.Admin", + "DeveloperApp", + "DisplayApp", + "ContentAuthor", + "ConsolidationScope", + "Tutorial.Author", + "authenticated-user" + ] }, "superadmin": { "password": "superadmin", - "roles": ["SuperAdmin", "Admin", "DeveloperApp", "DisplayApp", "authenticated-user"] + "roles": [ + "SuperAdmin", + "Admin", + "DeveloperApp", + "DisplayApp", + "authenticated-user" + ] }, "display": { "password": "display", - "roles": ["DisplayApp", "authenticated-user"] + "roles": [ + "DisplayApp", + "authenticated-user" + ] }, "consolidation": { "password": "consolidation", - "roles": ["ConsolidationScope", "authenticated-user"] + "roles": [ + "ConsolidationScope", + "authenticated-user" + ] }, "author": { - "roles": ["Tutorial.Author", "authenticated-user"] + "roles": [ + "Tutorial.Author", + "authenticated-user" + ] }, "idpcustom": { - "roles": ["authenticated-user"], - "attr": { "origin": "sap.custom" } + "roles": [ + "authenticated-user" + ], + "attr": { + "origin": "sap.custom" + } }, "idpdefault": { - "roles": ["authenticated-user"], - "attr": { "origin": "sap.default" } + "roles": [ + "authenticated-user" + ], + "attr": { + "origin": "sap.default" + } }, "apiuser": { "password": "apikey", - "roles": ["Tutorial.API", "authenticated-user"] + "roles": [ + "Tutorial.API", + "authenticated-user" + ] + }, + "withpicture": { + "roles": [ + "authenticated-user" + ], + "attr": { + "picture": "https://avatars.githubusercontent.com/u/42?v=4" + } } } }, diff --git a/approuter/lib/github-oidc-shim.js b/approuter/lib/github-oidc-shim.js index c7ac933ac..e86fb4da1 100644 --- a/approuter/lib/github-oidc-shim.js +++ b/approuter/lib/github-oidc-shim.js @@ -176,6 +176,7 @@ async function fetchGithubProfile(accessToken) { name: user.name || user.login || null, email, emailVerified, + avatarUrl: user.avatar_url || null, // → picture claim; may rotate, so re-stamped each login } } @@ -194,7 +195,7 @@ function discoveryDocument(baseUrl) { scopes_supported: ['openid', 'profile', 'email'], token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'], code_challenge_methods_supported: ['S256'], - claims_supported: ['sub', 'iss', 'aud', 'exp', 'iat', 'nonce', 'preferred_username', 'email', 'email_verified', 'name'], + claims_supported: ['sub', 'iss', 'aud', 'exp', 'iat', 'nonce', 'preferred_username', 'email', 'email_verified', 'name', 'picture'], } } @@ -212,6 +213,7 @@ async function mintIdToken(baseUrl, aud, profile, nonce) { email: profile.email ?? undefined, email_verified: profile.email ? profile.emailVerified : undefined, name: profile.name ?? undefined, + picture: profile.avatarUrl ?? undefined, nonce: nonce ?? undefined, }) .setProtectedHeader({ alg: ALG, kid }) diff --git a/approuter/xs-app.json b/approuter/xs-app.json index 0a429436c..3e385a30c 100644 --- a/approuter/xs-app.json +++ b/approuter/xs-app.json @@ -6,7 +6,7 @@ "responseHeaders": [ { "name": "Content-Security-Policy", - "value": "default-src 'self'; frame-ancestors 'self' https://*.sap.com https://*.sap.cn https://*.cloud.sap https://hybris.com https://*.hybris.com https://*.lookbookhq.com https://www.gigya.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://ui5.sap.com https://www.youtube.com https://consent.trustarc.com; worker-src 'self' blob:; frame-src https://www.youtube.com https://youtube.com https://youtu.be https://microlearning.opensap.com https://sapvideo.cfapps.eu10-004.hana.ondemand.com https://player.vimeo.com https://vimeo.com; media-src 'self' https://raw.githubusercontent.com; img-src 'self' blob: https://raw.githubusercontent.com https://avatars.githubusercontent.com https://github.com https://*.sap.com https://i.ytimg.com https://static.rainfocus.com data: https://consent.trustarc.com; style-src 'self' 'unsafe-inline' https://*.sap.com https://ui5.sap.com https://unpkg.com; font-src 'self' https://*.sap.com https://ui5.sap.com https://unpkg.com https://cdn.jsdelivr.net https://consent.trustarc.com; connect-src 'self' blob: https://ui5.sap.com https://*.cfapps.eu10-005.hana.ondemand.com wss://*.cfapps.eu10-005.hana.ondemand.com https://consent.trustarc.com https://user-consent-center.trustarc.com" + "value": "default-src 'self'; frame-ancestors 'self' https://*.sap.com https://*.sap.cn https://*.cloud.sap https://hybris.com https://*.hybris.com https://*.lookbookhq.com https://www.gigya.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://ui5.sap.com https://www.youtube.com https://consent.trustarc.com; worker-src 'self' blob:; frame-src https://www.youtube.com https://youtube.com https://youtu.be https://microlearning.opensap.com https://sapvideo.cfapps.eu10-004.hana.ondemand.com https://player.vimeo.com https://vimeo.com; media-src 'self' https://raw.githubusercontent.com; img-src 'self' blob: https://raw.githubusercontent.com https://avatars.githubusercontent.com https://github.com https://lh3.googleusercontent.com https://media.licdn.com https://huggingface.co https://cdn-avatars.huggingface.co https://*.sap.com https://i.ytimg.com https://static.rainfocus.com data: https://consent.trustarc.com; style-src 'self' 'unsafe-inline' https://*.sap.com https://ui5.sap.com https://unpkg.com; font-src 'self' https://*.sap.com https://ui5.sap.com https://unpkg.com https://cdn.jsdelivr.net https://consent.trustarc.com; connect-src 'self' blob: https://ui5.sap.com https://*.cfapps.eu10-005.hana.ondemand.com wss://*.cfapps.eu10-005.hana.ondemand.com https://consent.trustarc.com https://user-consent-center.trustarc.com" }, { "name": "Referrer-Policy", diff --git a/db/schema.cds b/db/schema.cds index 7eb335126..cd55dbfed 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -172,6 +172,12 @@ entity Users : cuid, managed, LegacyKeyed { khorosLogin : String(64); khorosAvatarUrl : String(1000); khorosLinkedAt : Timestamp; + // Login-provider profile picture (#2628) — OIDC `picture` claim: GitHub + // avatar_url via the shim, or Google/LinkedIn/Hugging Face via IAS attribute + // mapping. Advisory, not identity; refreshed to the latest value on every + // login (URLs rotate/re-sign) by refreshUserPicture. Nullable. The Khoros + // avatar above takes precedence in the UI when the user has linked it. + pictureUrl : String(1024); // GitHub login (e.g. "jung-thomas") — canonical signal for Tutorials.author // resolution at publish time (issue #777 followup). Source: parsed from // author_profile URL in tutorial markdown frontmatter. Backfilled from diff --git a/hugo/layouts/partials/header.html b/hugo/layouts/partials/header.html index 8cb961c0a..4e6b58577 100644 --- a/hugo/layouts/partials/header.html +++ b/hugo/layouts/partials/header.html @@ -381,17 +381,21 @@ } } - function applyKhorosAvatar(khorosAvatarUrl, givenName) { - // Remove any previously injected khoros children + function applyKhorosAvatar(avatarUrl, givenName) { + // #2628: renders whichever avatar won precedence. The server resolves + // `avatarUrl` as Khoros (SAP Community) → login-provider picture → null, + // so this one path covers both sources; a broken URL falls back to the + // initials already stamped by applyInitials. Class name kept as + // `khoros-avatar` so the existing CSS sizing/mask still applies. [avatar, avatarLarge].forEach(el => { const prev = el.querySelector('img.khoros-avatar'); if (prev) prev.remove(); }); - if (!khorosAvatarUrl) return; + if (!avatarUrl) return; [avatar, avatarLarge].forEach(el => { const img = document.createElement('img'); img.className = 'khoros-avatar'; - img.src = khorosAvatarUrl; + img.src = avatarUrl; img.alt = givenName || 'profile'; img.onerror = () => img.remove(); // broken URL falls back to initials el.appendChild(img); @@ -422,7 +426,11 @@ userEmail.textContent = u.email || ''; document.documentElement.dataset.authenticated = 'true'; applyInitials(given, family, id); - applyKhorosAvatar(u.khorosAvatarUrl || null, given); + // #2628: Khoros avatar wins, login-provider picture is the fallback. The + // server pre-resolves `avatarUrl` with that precedence; keep a client-side + // fallback (khorosAvatarUrl || pictureUrl) for cached users written before + // the field existed. + applyKhorosAvatar(u.avatarUrl || u.khorosAvatarUrl || u.pictureUrl || null, given); avatar.setAttribute('accessible-name', userName.textContent); document.documentElement.dataset.user = JSON.stringify({ firstName: given }); document.querySelectorAll('.nav-auth-only').forEach(el => el.removeAttribute('hidden')); @@ -506,7 +514,7 @@ if (cached) applyAuthenticatedUser(cached); return; } - const toCache = { firstName: user.givenName, givenName: user.givenName, familyName: user.familyName, email: user.email, id: user.id, isAdmin: user.isAdmin === true, identityProvider: user.identityProvider ?? null, khorosId: user.khorosId ?? null, khorosLogin: user.khorosLogin ?? null, khorosAvatarUrl: user.khorosAvatarUrl ?? null }; + const toCache = { firstName: user.givenName, givenName: user.givenName, familyName: user.familyName, email: user.email, id: user.id, isAdmin: user.isAdmin === true, identityProvider: user.identityProvider ?? null, khorosId: user.khorosId ?? null, khorosLogin: user.khorosLogin ?? null, khorosAvatarUrl: user.khorosAvatarUrl ?? null, pictureUrl: user.pictureUrl ?? null, avatarUrl: user.avatarUrl ?? null }; try { sessionStorage.setItem(USER_KEY, JSON.stringify({ ts: Date.now(), value: toCache })); } catch {} applyAuthenticatedUser(user); // Live auth success: supersedes any prior intentional logout — re-enable diff --git a/packages/core/resolve-db-user.js b/packages/core/resolve-db-user.js index 317006b88..78ff95638 100644 --- a/packages/core/resolve-db-user.js +++ b/packages/core/resolve-db-user.js @@ -496,6 +496,41 @@ export async function backfillUserProfile(user) { return { backfilled: true, fields: Object.keys(updates) }; } +/** + * Issue #2628: refresh the login-provider profile picture (OIDC `picture` + * claim) on Users.pictureUrl. + * + * Unlike backfillUserProfile (blanks-only self-heal for firstName/lastName/ + * email), the picture URL is NOT stable: GitHub bumps `?v=`, LinkedIn/Google + * return short-lived signed URLs. So this is deliberately "always overwrite + * with the latest non-null claim" rather than blanks-only — the row should + * carry whatever the most recent login produced. + * + * Preservation rule: a token WITHOUT a picture claim never clears an existing + * value. A provider that stops emitting the claim (or a login via SAP ID / + * Apple, which never do) must not wipe a good picture captured earlier. + * + * UPDATE-only, keyed on sapId — provisionDbUser handles the INSERT path by + * seeding pictureUrl directly on row creation. + * + * @returns {Promise<{updated: boolean, reason?: string}>} + */ +export async function refreshUserPicture(user) { + const sapId = resolveUserSapId(user); + if (!sapId) return { updated: false, reason: 'anonymous' }; + + const claimPicture = user.attr?.picture || null; + if (!claimPicture) return { updated: false, reason: 'no-claim' }; + + const { Users } = cds.entities('com.sap.developers.ims'); + const dbUser = await SELECT.one.from(Users).where({ sapId }).columns('ID', 'pictureUrl'); + if (!dbUser) return { updated: false, reason: 'no-user' }; + if (dbUser.pictureUrl === claimPicture) return { updated: false, reason: 'unchanged' }; + + await UPDATE(Users).where({ sapId }).set({ pictureUrl: claimPicture }); + return { updated: true }; +} + /** * Get-or-create the Users row for the authenticated request, keyed on sapId. * @@ -563,6 +598,8 @@ export async function provisionDbUser(user, columns) { if (existing) { await backfillUserProfile(user).catch((err) => cds.log('resolve-db-user').warn('[provision-backfill]', err?.message ?? err)); + await refreshUserPicture(user).catch((err) => + cds.log('resolve-db-user').warn('[provision-picture]', err?.message ?? err)); // Ensure a (iss,sub) link exists even if the hit came via Tier 1 already // (no-op on duplicate). Tiers 2/3 wrote one; Tier 1 means it exists. if (columns && columns.length) { @@ -578,6 +615,7 @@ export async function provisionDbUser(user, columns) { const claimFirstName = user.attr?.given_name || user.attr?.givenName; const claimLastName = user.attr?.family_name || user.attr?.familyName; const claimEmail = emailFromUser(user); + const claimPicture = user.attr?.picture || null; if (!claimEmail && !claimFirstName && !claimLastName && !canonicalSapId) return null; const db = await cds.connect.to('db'); @@ -591,6 +629,7 @@ export async function provisionDbUser(user, columns) { email: claimEmail || null, firstName: claimFirstName || null, lastName: claimLastName || null, + pictureUrl: claimPicture, // login-provider avatar (#2628), nullable }); } catch (err) { if (!/unique|duplicate/i.test(String(err?.message ?? ''))) throw err; diff --git a/srv/lib/resolve-db-user.js b/srv/lib/resolve-db-user.js index b335a1e87..6b029580c 100644 --- a/srv/lib/resolve-db-user.js +++ b/srv/lib/resolve-db-user.js @@ -6,6 +6,7 @@ export const resolveUserSapId = mod.resolveUserSapId; export const resolveDbUser = mod.resolveDbUser; export const emailFromUser = mod.emailFromUser; export const backfillUserProfile = mod.backfillUserProfile; +export const refreshUserPicture = mod.refreshUserPicture; export const provisionDbUser = mod.provisionDbUser; export const resolveUser = mod.resolveUser; export const issuerSubjectFromUser = mod.issuerSubjectFromUser; diff --git a/srv/server.js b/srv/server.js index 6cf626a8e..d3db837a1 100644 --- a/srv/server.js +++ b/srv/server.js @@ -1992,12 +1992,17 @@ cds.on('served', async () => { let khorosId = null; let khorosLogin = null; let khorosAvatarUrl = null; + // #2628: login-provider profile picture (OIDC `picture` claim). The live + // token claim is freshest (URLs rotate), so prefer it; fall back to the + // value refreshUserPicture persisted on the Users row at login. + let pictureUrl = user.attr?.picture || null; try { - const dbUser = await resolveDbUser(user, ['khorosId', 'khorosLogin', 'khorosAvatarUrl']); + const dbUser = await resolveDbUser(user, ['khorosId', 'khorosLogin', 'khorosAvatarUrl', 'pictureUrl']); if (dbUser) { khorosId = dbUser.khorosId ?? null; khorosLogin = dbUser.khorosLogin ?? null; khorosAvatarUrl = dbUser.khorosAvatarUrl ?? null; + if (!pictureUrl) pictureUrl = dbUser.pictureUrl ?? null; } } catch (err) { console.warn('[auth/user] khoros lookup failed (non-fatal):', err.message); @@ -2024,6 +2029,12 @@ cds.on('served', async () => { khorosId, khorosLogin, khorosAvatarUrl, + // #2628: login-provider picture + resolved avatar. Precedence per the + // approved design: Khoros (SAP Community) avatar wins, login-provider + // picture is the fallback. The client can also apply this itself, but a + // server-resolved `avatarUrl` keeps the rule in one place. + pictureUrl, + avatarUrl: khorosAvatarUrl || pictureUrl || null, }); }); diff --git a/test/backfill-user-profile.test.js b/test/backfill-user-profile.test.js index 5755af480..6a3612609 100644 --- a/test/backfill-user-profile.test.js +++ b/test/backfill-user-profile.test.js @@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import cds from '@sap/cds'; -import { backfillUserProfile } from '../srv/lib/resolve-db-user.js'; +import { backfillUserProfile, refreshUserPicture } from '../srv/lib/resolve-db-user.js'; cds.test('serve', '--project', '.', '--in-memory'); @@ -195,3 +195,70 @@ describe('backfillUserProfile (Issue #339)', () => { expect(second.reason).toBe('no-blanks'); }); }); + +// Issue #2628: the login-provider profile picture (OIDC `picture` claim — +// GitHub avatar_url via the shim, or Google/LinkedIn/HF via IAS mapping). +// Unlike firstName/lastName/email (blanks-only self-heal), the picture URL +// ROTATES (GitHub ?v= bumps, LinkedIn re-signs), so refreshUserPicture is +// "always overwrite with the latest non-null claim", NOT blanks-only. +describe('refreshUserPicture (Issue #2628)', () => { + let Users; + + beforeAll(async () => { Users = cds.entities('com.sap.developers.ims').Users; }); + afterAll(async () => { await DELETE.from(Users).where({ sapId: SAP_ID }); }); + + const seedUser = async (pictureUrl = null) => { + await DELETE.from(Users).where({ sapId: SAP_ID }); + await INSERT.into(Users).entries({ + ID: USER_UUID, uuid: USER_UUID, sapId: SAP_ID, pictureUrl, + }); + }; + const buildUser = (claims = {}) => ({ + id: 'someone@example.com', authInfo: { token: { userId: SAP_ID } }, attr: claims, + }); + + it('writes pictureUrl from the token picture claim when the row has none', async () => { + await seedUser(null); + const verdict = await refreshUserPicture(buildUser({ picture: 'https://img/a.png' })); + expect(verdict.updated).toBe(true); + const row = await SELECT.one.from(Users).where({ sapId: SAP_ID }); + expect(row.pictureUrl).toBe('https://img/a.png'); + }); + + it('OVERWRITES an existing pictureUrl when the claim changed (rotation)', async () => { + await seedUser('https://img/old.png?v=3'); + const verdict = await refreshUserPicture(buildUser({ picture: 'https://img/new.png?v=4' })); + expect(verdict.updated).toBe(true); + const row = await SELECT.one.from(Users).where({ sapId: SAP_ID }); + expect(row.pictureUrl).toBe('https://img/new.png?v=4'); + }); + + it('no-ops when the claim equals the stored value', async () => { + await seedUser('https://img/same.png'); + const verdict = await refreshUserPicture(buildUser({ picture: 'https://img/same.png' })); + expect(verdict.updated).toBe(false); + expect(verdict.reason).toBe('unchanged'); + }); + + it('does NOT clear an existing pictureUrl when the token carries no picture claim', async () => { + await seedUser('https://img/keep.png'); + const verdict = await refreshUserPicture(buildUser({})); // no picture claim + expect(verdict.updated).toBe(false); + expect(verdict.reason).toBe('no-claim'); + const row = await SELECT.one.from(Users).where({ sapId: SAP_ID }); + expect(row.pictureUrl).toBe('https://img/keep.png'); // preserved, not nulled + }); + + it('no-ops (no-user) when no Users row exists', async () => { + await DELETE.from(Users).where({ sapId: SAP_ID }); + const verdict = await refreshUserPicture(buildUser({ picture: 'https://img/a.png' })); + expect(verdict.updated).toBe(false); + expect(verdict.reason).toBe('no-user'); + }); + + it('returns anonymous when the user is unauthenticated', async () => { + const verdict = await refreshUserPicture({ id: 'anonymous' }); + expect(verdict.updated).toBe(false); + expect(verdict.reason).toBe('anonymous'); + }); +}); diff --git a/test/unit/auth-user-endpoint.test.js b/test/unit/auth-user-endpoint.test.js index 8c9886e16..e23e7a493 100644 --- a/test/unit/auth-user-endpoint.test.js +++ b/test/unit/auth-user-endpoint.test.js @@ -90,4 +90,30 @@ describe('/auth/user', () => { expect(body).toHaveProperty('identityProvider'); expect(body.identityProvider).toBeNull(); }); + + // Issue #2628 — surface the login-provider profile picture (OIDC `picture` + // claim). The live token claim is freshest, so it wins over the persisted + // Users.pictureUrl; with no Khoros link, avatarUrl resolves to it. + it('exposes attr.picture as pictureUrl and avatarUrl (no Khoros link)', async () => { + const credentials = Buffer.from('withpicture:').toString('base64'); + const res = await fetch(`${baseUrl}/auth/user`, { + headers: { Authorization: `Basic ${credentials}` } + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.pictureUrl).toBe('https://avatars.githubusercontent.com/u/42?v=4'); + expect(body.avatarUrl).toBe('https://avatars.githubusercontent.com/u/42?v=4'); + }); + + it('emits pictureUrl:null and avatarUrl:null for a caller with no picture claim', async () => { + const credentials = Buffer.from('author:').toString('base64'); + const res = await fetch(`${baseUrl}/auth/user`, { + headers: { Authorization: `Basic ${credentials}` } + }); + const body = await res.json(); + expect(body).toHaveProperty('pictureUrl'); + expect(body).toHaveProperty('avatarUrl'); + expect(body.pictureUrl).toBeNull(); + expect(body.avatarUrl).toBeNull(); + }); }); diff --git a/test/unit/github-oidc-shim.test.js b/test/unit/github-oidc-shim.test.js index 50da7ee31..8c1c04852 100644 --- a/test/unit/github-oidc-shim.test.js +++ b/test/unit/github-oidc-shim.test.js @@ -140,6 +140,22 @@ describe('id_token minting', () => { expect(payload.email).toBeUndefined() expect(payload.email_verified).toBeUndefined() }) + + it('stamps the picture claim from the profile avatar URL', async () => { + const profile = { id: '9', login: 'octocat', name: 'The Octocat', email: null, emailVerified: false, avatarUrl: 'https://avatars.githubusercontent.com/u/9?v=4' } + const idToken = await shim.mintIdToken(BASE_URL, 'aud', profile, null) + const jwks = await keys.getJwks() + const { payload } = await jwtVerify(idToken, createLocalJWKSet(jwks), { issuer: BASE_URL, audience: 'aud' }) + expect(payload.picture).toBe('https://avatars.githubusercontent.com/u/9?v=4') + }) + + it('omits the picture claim when the profile has no avatar URL', async () => { + const profile = { id: '10', login: 'ghost', name: null, email: null, emailVerified: false } + const idToken = await shim.mintIdToken(BASE_URL, 'aud', profile, null) + const jwks = await keys.getJwks() + const { payload } = await jwtVerify(idToken, createLocalJWKSet(jwks), { issuer: BASE_URL, audience: 'aud' }) + expect(payload.picture).toBeUndefined() + }) }) describe('PKCE S256 verification', () => { From b58bfb00828501d883cdeee14d5d973b9802329d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:29:44 -0400 Subject: [PATCH 21/37] feat(#2585): mirror quarantine-events route + entities onto srv-qa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-drift guard (check-srv-qa-route-drift.ts) requires /content/* routes to exist on both srv and srv-qa unless allowlisted. CI reporter routes are mirrored, not allowlisted — so POST /content/quarantine-events is added to srv-qa, and QuarantineSnapshots/QuarantineEvents are cloned into db-qa/schema.cds (the QA model does not load prod .cds files, so the handler's cds.entities('.qa') lookup would 500 without the clones — the same failure mode documented for /content/validate-answer-specs). --- db-qa/schema.cds | 24 ++++++++++++++++++++++++ srv-qa/server.js | 7 ++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/db-qa/schema.cds b/db-qa/schema.cds index 906b527d1..b148d6e00 100644 --- a/db-qa/schema.cds +++ b/db-qa/schema.cds @@ -80,3 +80,27 @@ entity JobLogItems : cuid { status : String(10) enum { SUCCESS; SKIPPED; WARN; ERROR; }; message : String(2000); } + +// Pre-publish quarantine snapshots (#2585) — mirrors prod entities +// (db/tutorial-quarantine.cds); required by quarantineIngestHandler when +// content-store runs under the QA namespace. A full QA rebuild posts its +// quarantine snapshot here so QA parity matches prod. Shape kept identical +// to the prod definitions so the deep-insert handler resolves cleanly. +entity QuarantineSnapshots : cuid, managed { + runId : String(60); + workflowUrl : String(400); + manifestVersion : String(60); + buildMode : String(20) default 'full'; + isCurrent : Boolean default false; + eventCount : Integer default 0; + events : Composition of many QuarantineEvents on events.snapshot = $self; +} + +entity QuarantineEvents : cuid, managed { + snapshot : Association to QuarantineSnapshots; + slug : String(255) @mandatory; + sourceFile : String(255); + sourceRepo : String(120); + reason : String(500) @mandatory; + sourceUrl : String(600); +} diff --git a/srv-qa/server.js b/srv-qa/server.js index 15ce860af..4d9f5bb64 100644 --- a/srv-qa/server.js +++ b/srv-qa/server.js @@ -23,7 +23,7 @@ cds.on('bootstrap', (app) => { } }); - const { serveHandler, navHandler, hashesHandler, sourceHashesHandler, publishHandler, rollbackHandler, beginHandler, appendHandler, commitHandler, abortHandler, contentAuthMiddleware, pipelineLogFailureHandler, pageServeHandler } = + const { serveHandler, navHandler, hashesHandler, sourceHashesHandler, publishHandler, rollbackHandler, beginHandler, appendHandler, commitHandler, abortHandler, contentAuthMiddleware, pipelineLogFailureHandler, quarantineIngestHandler, pageServeHandler } = createContentHandlers({ namespace: 'com.sap.developers.ims.qa', apiKeyEnv: 'CONTENT_API_KEY_QA', skipMetadataUpsert: true }); // GET handlers serve in-flight author content from -Contribution repos. The @@ -109,6 +109,11 @@ cds.on('bootstrap', (app) => { // endpoint. Writes a FAILED PipelineLog row in the QA namespace so QA build/ // publish failures show up in the admin dashboard, not just a red CI run. app.post('/content/pipeline-log', express.json({ limit: '256kb' }), contentAuthMiddleware, pipelineLogFailureHandler); + // CI quarantine-snapshot reporter (#2585) — mirrors the prod srv endpoint. + // A full QA rebuild posts its pre-publish quarantine snapshot into the QA + // namespace (QuarantineSnapshots/QuarantineEvents, cloned in db-qa/schema.cds) + // so QA parity matches prod. Handler resolves entities via the QA namespace. + app.post('/content/quarantine-events', express.json({ limit: '2mb' }), contentAuthMiddleware, quarantineIngestHandler); app.post('/content/rollback', express.json(), contentAuthMiddleware, rollbackHandler); // NOTE (#1375): /content/validate-answer-specs is intentionally NOT // registered on srv-qa. The handler resolves entities from the prod From ee8793a1671d973cee8a3dc0e404c7954643f4d1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:30:23 -0400 Subject: [PATCH 22/37] feat(#2585): register quarantineEvents in admin-shell + fix generator prefix collision Task 6 added the app/admin/quarantine-events/ component folder but never registered it in the shell manifest. Register it via the sanctioned generator path: add order + 'qu' prefix overrides, regenerate manifest (50->51 components), add the nav item to navigation.json's System group, and wire NAV_KEY_TO_ROUTE / NAV_KEY_TO_TITLE in Shell.controller.js. Also fixes a pre-existing latent generator bug: semaphoreConfig had no prefix override and auto-picked 'se', colliding with secrets. Added an explicit semaphoreConfig: 'sm' override so generate-manifest runs clean. --- .../scripts/admin-shell-overrides.js | 4 ++ .../webapp/controller/Shell.controller.js | 6 +- app/admin-shell/webapp/manifest.json | 72 ++++++++++++------- app/admin-shell/webapp/model/navigation.json | 1 + 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/app/admin-shell/scripts/admin-shell-overrides.js b/app/admin-shell/scripts/admin-shell-overrides.js index afcc3ed55..d96805573 100644 --- a/app/admin-shell/scripts/admin-shell-overrides.js +++ b/app/admin-shell/scripts/admin-shell-overrides.js @@ -67,6 +67,8 @@ module.exports = { 'topicClusters', 'feedback', 'secrets', + 'semaphoreConfig', + 'quarantine-events', 'uiEvents', 'search', 'navigator', @@ -153,6 +155,8 @@ module.exports = { topicClusters: 'tc', feedback: 'fb', secrets: 'se', + semaphoreConfig: 'sm', + 'quarantine-events': 'qu', uiEvents: 'ue', search: 'sr', navigator: 'nv', diff --git a/app/admin-shell/webapp/controller/Shell.controller.js b/app/admin-shell/webapp/controller/Shell.controller.js index 893f6ff64..78f3c37de 100644 --- a/app/admin-shell/webapp/controller/Shell.controller.js +++ b/app/admin-shell/webapp/controller/Shell.controller.js @@ -67,7 +67,8 @@ sap.ui.define([ channelCollections: "channelCollections", channelTopicMap: "channelTopicMap", channelSubmissions: "channelSubmissions", - semaphoreConfig: "semaphoreConfig" + semaphoreConfig: "semaphoreConfig", + quarantineEvents: "quarantineEvents" }; var NAV_KEY_TO_TITLE = { @@ -131,7 +132,8 @@ sap.ui.define([ channelCollections: "Channel Collections", channelTopicMap: "Channel Topic Map", channelSubmissions: "Channel Submissions", - semaphoreConfig: "Semaphore Config" + semaphoreConfig: "Semaphore Config", + quarantineEvents: "Quarantined Tutorials" }; return Controller.extend("sap.tutorials.admin.shell.controller.Shell", { diff --git a/app/admin-shell/webapp/manifest.json b/app/admin-shell/webapp/manifest.json index 692f9a6ea..977cef77a 100644 --- a/app/admin-shell/webapp/manifest.json +++ b/app/admin-shell/webapp/manifest.json @@ -80,6 +80,8 @@ "sap.tutorials.admin.topicClusters": "./components/topicClusters", "sap.tutorials.admin.feedback": "./components/feedback", "sap.tutorials.admin.secrets": "./components/secrets", + "sap.tutorials.admin.semaphoreConfig": "./components/semaphoreConfig", + "sap.tutorials.admin.quarantineEvents": "./components/quarantine-events", "sap.tutorials.admin.uiEvents": "./components/uiEvents", "sap.tutorials.admin.search": "./components/search", "sap.tutorials.admin.navigator": "./components/navigator", @@ -95,7 +97,6 @@ "sap.tutorials.admin.videoRotation": "./components/video-rotation", "sap.tutorials.admin.pats": "./components/pats", "sap.tutorials.admin.featureFlags": "./components/featureFlags", - "sap.tutorials.admin.semaphoreConfig": "./components/semaphoreConfig", "sap.tutorials.admin.petoberfest": "./components/petoberfest", "sap.tutorials.admin.channels": "./components/channels", "sap.tutorials.admin.channelCollections": "./components/channel-collections", @@ -303,6 +304,18 @@ "componentData": {}, "lazy": true }, + "semaphoreConfigComponent": { + "name": "sap.tutorials.admin.semaphoreConfig", + "settings": {}, + "componentData": {}, + "lazy": true + }, + "quarantineEventsComponent": { + "name": "sap.tutorials.admin.quarantineEvents", + "settings": {}, + "componentData": {}, + "lazy": true + }, "uiEventsSettingsComponent": { "name": "sap.tutorials.admin.uiEvents", "settings": {}, @@ -393,12 +406,6 @@ "componentData": {}, "lazy": true }, - "semaphoreConfigComponent": { - "name": "sap.tutorials.admin.semaphoreConfig", - "settings": {}, - "componentData": {}, - "lazy": true - }, "petoberfestComponent": { "name": "sap.tutorials.admin.petoberfest", "settings": {}, @@ -768,6 +775,26 @@ } ] }, + { + "name": "semaphoreConfig", + "pattern": "semaphoreConfig", + "target": [ + { + "name": "semaphoreConfigTarget", + "prefix": "sm" + } + ] + }, + { + "name": "quarantineEvents", + "pattern": "quarantine-events", + "target": [ + { + "name": "quarantineEventsTarget", + "prefix": "qu" + } + ] + }, { "name": "uiEvents", "pattern": "uiEvents", @@ -938,16 +965,6 @@ } ] }, - { - "name": "semaphoreConfig", - "pattern": "semaphoreConfig", - "target": [ - { - "name": "semaphoreConfigTarget", - "prefix": "smc" - } - ] - }, { "name": "petoberfest", "pattern": "petoberfest", @@ -1253,6 +1270,20 @@ "viewLevel": 1, "prefix": "se" }, + "semaphoreConfigTarget": { + "type": "Component", + "usage": "semaphoreConfigComponent", + "id": "semaphoreConfigTarget", + "viewLevel": 1, + "prefix": "sm" + }, + "quarantineEventsTarget": { + "type": "Component", + "usage": "quarantineEventsComponent", + "id": "quarantineEventsTarget", + "viewLevel": 1, + "prefix": "qu" + }, "uiEventsSettingsTarget": { "type": "Component", "usage": "uiEventsSettingsComponent", @@ -1358,13 +1389,6 @@ "viewLevel": 1, "prefix": "ffl" }, - "semaphoreConfigTarget": { - "type": "Component", - "usage": "semaphoreConfigComponent", - "id": "semaphoreConfigTarget", - "viewLevel": 1, - "prefix": "smc" - }, "petoberfestTarget": { "type": "Component", "usage": "petoberfestComponent", diff --git a/app/admin-shell/webapp/model/navigation.json b/app/admin-shell/webapp/model/navigation.json index 5dce31908..b3daafc98 100644 --- a/app/admin-shell/webapp/model/navigation.json +++ b/app/admin-shell/webapp/model/navigation.json @@ -93,6 +93,7 @@ "items": [ { "key": "pipelinelog", "title": "Pipeline Log" }, { "key": "joblog", "title": "Job Log" }, + { "key": "quarantineEvents", "title": "Quarantined Tutorials", "requiredScope": "Admin" }, { "key": "accounts", "title": "Account Merges" }, { "key": "changelog", "title": "Change Log", "requiredScope": "Tutorial.Author", "adminPath": "/admin/", "authorPath": "/author/" }, { "key": "board", "title": "Board" }, From 6329ca61f5a61e4484a89f977fcc4109b4bfd2d1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:30:59 -0400 Subject: [PATCH 23/37] docs(#2585): add tutorial-quarantine-persistence implementation plan --- ...6-10-04-tutorial-quarantine-persistence.md | 853 ++++++++++++++++++ 1 file changed, 853 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-tutorial-quarantine-persistence.md diff --git a/docs/superpowers/plans/2026-10-04-tutorial-quarantine-persistence.md b/docs/superpowers/plans/2026-10-04-tutorial-quarantine-persistence.md new file mode 100644 index 000000000..cc9a5db4c --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-tutorial-quarantine-persistence.md @@ -0,0 +1,853 @@ +# Tutorial Quarantine Persistence + Admin UI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist tutorial pre-publish quarantine results to HANA via an authenticated CI endpoint and surface the current set in the Admin UI, so quarantine reasons are no longer discarded on the ephemeral CI runner. + +**Architecture:** A full rebuild (`mode=full`) posts its complete quarantine set to a new `POST /content/quarantine-events` Express route (reusing the existing `contentAuthMiddleware`). The handler writes an immutable snapshot (`QuarantineSnapshots`) + child rows (`QuarantineEvents`) in one transaction and flips the prior snapshot's `isCurrent` flag off, so "current quarantine set" = the latest snapshot's children. A Fiori Elements list view in the Admin UI binds to a `QuarantineEventsCurrent` projection; raw snapshot/event projections drive over-time / per-repo reporting. + +**Tech Stack:** CAP (Node.js, CDS), SAP HANA Cloud, Express, Fiori Elements (sap.fe.templates), Vitest, TypeScript (tsx) for the validator script, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-10-04-tutorial-quarantine-persistence-design.md` + +## Global Constraints + +- Namespace is `com.sap.developers.ims` everywhere (DB entities, `cds.entities(NS)`). +- Never write raw SQL — use `cds.ql` / CQL (`SELECT`/`INSERT`/`UPDATE`/`DELETE`). Raw `db.run()` is ONLY for HANA BLOB locators; quarantine entities have no BLOBs, so CDS QL throughout. +- Ingest endpoint auth: Bearer token = `CONTENT_API_KEY` via the existing `contentAuthMiddleware` (503 unconfigured / 401 missing-or-malformed bearer / 403 wrong key, `timingSafeEqual`). Do not invent a new key. +- The validator (`scripts/validate-tutorials.ts`) MUST remain fail-open: it never exits non-zero and never throws on a failed network post. It runs in two places — `rebuild-content.yml` AND the `mta.yaml` before-all build (where no CAP backend exists) — so the post MUST be a silent no-op unless `QUARANTINE_REPORT === 'full'` AND both `CAP_BASE_URL` and `CONTENT_API_KEY` are set. +- Only `mode=full` writes a snapshot; non-full `buildMode` is rejected 400 by the handler. +- House DB style: `: cuid, managed` (UUID `ID` + managed timestamps), `@mandatory` on required strings, status fields `String(N) default '...'`. +- Admin UI entities are auto-discovered — do NOT edit the admin-shell generated manifest. Admin-UI changes require a FULL deploy (no `--skip-build`, no `-m` scoping); Step 3.5 bundle-drift guard enforces this. +- Fiori app id MUST be `sap.tutorials.admin.quarantineEvents` (enforced at discover time by `app/admin-shell/scripts/discover-admin-components.js`). +- PRs target `DEV`, never `main`. Work is on branch `worktree-quarantine-2585` (based on `origin/DEV`). + +--- + +## File Structure + +- **Create** `db/tutorial-quarantine.cds` — `QuarantineSnapshots` + `QuarantineEvents` entities. +- **Modify** `srv/admin-service.cds` — `using` import + 3 read-only projections (`QuarantineSnapshots`, `QuarantineEvents`, `QuarantineEventsCurrent`). +- **Modify** `app/admin-annotations.cds` — Fiori `@UI` annotations for `AdminService.QuarantineEventsCurrent`. +- **Create** `app/admin/quarantine-events/webapp/{Component.js,manifest.json,i18n/i18n.properties}` — Fiori Elements list view (auto-discovered). +- **Modify** `packages/content/content-store.js` — add `quarantineIngestHandler` inside `createContentHandlers`, add to the returned object + default re-export. +- **Modify** `srv/lib/content-store.js` — re-export `quarantineIngestHandler` from the shim. +- **Modify** `srv/server.js` — import + register `POST /content/quarantine-events`. +- **Create** `srv/__tests__/lib/quarantine-ingest-routes.test.js` — Vitest route tests. +- **Modify** `scripts/validate-tutorials.ts` — add `slug`/`sourceRepo` to entries + best-effort POST (extracted to a testable function). +- **Create** `test/validate-tutorials-quarantine-post.test.ts` — unit tests for the POST guard/fail-open behaviour. +- **Modify** `.github/workflows/rebuild-content.yml` — add `env:` block to the "Validate tutorials" step. + +--- + +## Task 1: DB entities — `db/tutorial-quarantine.cds` + +**Files:** +- Create: `db/tutorial-quarantine.cds` +- Test: covered via Task 4 (handler round-trip exercises the schema under `cds.test` in-memory) + +**Interfaces:** +- Consumes: `com.sap.developers.ims` namespace from `db/schema`. +- Produces: entities `com.sap.developers.ims.QuarantineSnapshots` (fields: `ID`, `runId`, `workflowUrl`, `manifestVersion`, `buildMode`, `isCurrent`, `eventCount`, `events` composition, managed fields) and `com.sap.developers.ims.QuarantineEvents` (fields: `ID`, `snapshot` assoc, `slug`, `sourceFile`, `sourceRepo`, `reason`, `sourceUrl`, managed fields). + +- [ ] **Step 1: Create the entity file** + +```cds +namespace com.sap.developers.ims; + +using { com.sap.developers.ims as ims } from './schema'; +using { cuid, managed } from '@sap/cds/common'; + +/** + * One row per full rebuild that posts a quarantine snapshot (#2585). + * The newest snapshot with isCurrent = true defines the live quarantine set; + * writing a new current snapshot flips the prior one to false. Snapshots are + * immutable once written — "clearing" a slug is just a later snapshot that + * does not contain it. + */ +entity QuarantineSnapshots : cuid, managed { + runId : String(60); // github.run_id + workflowUrl : String(400); // .../actions/runs/ + manifestVersion : String(60); // ContentManifest version at build time (nullable) + buildMode : String(20) default 'full'; // full (only full builds post) + isCurrent : Boolean default false; + eventCount : Integer default 0; + events : Composition of many QuarantineEvents on events.snapshot = $self; +} + +/** + * One quarantined slug within a snapshot: why it was dropped and where it came from. + */ +entity QuarantineEvents : cuid, managed { + snapshot : Association to QuarantineSnapshots; + slug : String(255) @mandatory; // lowercase canonical slug + sourceFile : String(255); // e.g. foo.md + sourceRepo : String(120); // from fetch _discovery.json repoBySlug + reason : String(500) @mandatory; // validator reason string + sourceUrl : String(600); // link to source .md in its repo (nullable) +} +``` + +- [ ] **Step 2: Verify the model compiles** + +Run: `npx cds compile db/tutorial-quarantine.cds` +Expected: compiles with no errors; prints CSN for both entities. (If it complains it cannot resolve `./schema`, run from repo root — the `using` resolves relative to the file.) + +- [ ] **Step 3: Verify it loads in the full model** + +Run: `npx cds compile srv --to sql 2>&1 | grep -i quarantine` +Expected: `CREATE TABLE` statements for `com_sap_developers_ims_QuarantineSnapshots` and `..._QuarantineEvents` appear (confirms it's picked up by the service model once Task 2 adds the projection — if run before Task 2, just confirm no compile error). + +- [ ] **Step 4: Commit** + +```bash +git add db/tutorial-quarantine.cds +git commit -m "feat(#2585): QuarantineSnapshots + QuarantineEvents entities" +``` + +--- + +## Task 2: AdminService projections — `srv/admin-service.cds` + +**Files:** +- Modify: `srv/admin-service.cds` (add `using` near the other `using` lines at the top, lines 1-15; add projections in the service body) +- Test: covered via Task 6 (annotations) and the handler test (Task 4) + +**Interfaces:** +- Consumes: entities from Task 1. +- Produces: `AdminService.QuarantineSnapshots`, `AdminService.QuarantineEvents`, `AdminService.QuarantineEventsCurrent` (read-only OData entity sets under `/admin`). `QuarantineEventsCurrent` exposes only events whose parent snapshot `isCurrent = true`. + +- [ ] **Step 1: Add the `using` import** + +Add alongside the existing `using from '../db/...'` lines near the top of `srv/admin-service.cds`: + +```cds +using from '../db/tutorial-quarantine'; +``` + +- [ ] **Step 2: Add the three projections in the AdminService body** + +Place near the other `@readonly` log-style projections (e.g. by `FreshnessReport` / `StepFailures`): + +```cds + @readonly entity QuarantineSnapshots as projection on ims.QuarantineSnapshots; + @readonly entity QuarantineEvents as projection on ims.QuarantineEvents; + + // Current quarantine set: events belonging to the one snapshot flagged + // isCurrent. Drives the default Admin list view with no filter ceremony. + @readonly entity QuarantineEventsCurrent as + projection on ims.QuarantineEvents + { *, snapshot.runId as runId, snapshot.workflowUrl as workflowUrl, snapshot.createdAt as buildAt } + where snapshot.isCurrent = true; +``` + +- [ ] **Step 3: Verify the service model compiles** + +Run: `npx cds compile srv --to sql 2>&1 | grep -iE "quarantine" | head` +Expected: the three entity sets resolve; no "Unknown ... QuarantineEvents" errors. A `where` on a projection is a view, which compiles to a `CREATE VIEW` / `SELECT` — confirm no compiler error about the association path `snapshot.isCurrent`. + +- [ ] **Step 4: Commit** + +```bash +git add srv/admin-service.cds +git commit -m "feat(#2585): AdminService projections for quarantine events" +``` + +--- + +## Task 3: Ingest handler — `packages/content/content-store.js` + shim + +**Files:** +- Modify: `packages/content/content-store.js` (add `quarantineIngestHandler` inside `createContentHandlers`, before the `return {` at line ~2157; add to the returned object ~2157-2176; add a default re-export ~2198) +- Modify: `srv/lib/content-store.js` (add re-export line after line 29) +- Test: `srv/__tests__/lib/quarantine-ingest-routes.test.js` (Task 4) + +**Interfaces:** +- Consumes: `namespace` + `apiKeyEnv` from the enclosing `createContentHandlers({ namespace, apiKeyEnv })` scope; `cds` global; `contentAuthMiddleware` (already in scope, applied at the route). The handler signature is `async function quarantineIngestHandler(req, res)`. +- Produces: export `quarantineIngestHandler` from both `packages/content/content-store.js` (named default export) and `srv/lib/content-store.js` (shim). Request body `{ runId, workflowUrl, manifestVersion?, buildMode, events: Array<{ slug, sourceFile?, sourceRepo?, reason, sourceUrl? }> }`. Response `201 { snapshotId, eventCount }`; `400` on non-full `buildMode` or non-array `events`; auth codes handled by middleware before the handler runs. + +- [ ] **Step 1: Add the handler inside `createContentHandlers`** + +Insert immediately before `return {` (the big handler-map return at ~line 2157), so it closes over `namespace`: + +```js + // --- quarantineIngestHandler: Express handler for /content/quarantine-events --- + // + // Called by scripts/validate-tutorials.ts on a FULL rebuild (#2585). Writes an + // immutable snapshot of the current quarantine set + child events, and flips any + // prior isCurrent snapshot off, so "current quarantine set" = the newest + // isCurrent snapshot's children. Only mode=full posts (a partial run's set would + // wrongly clear out-of-scope slugs). Idempotent per runId: a repeat POST for the + // same runId replaces that run's snapshot. Same auth as /content/publish. + async function quarantineIngestHandler(req, res) { + try { + const { runId, workflowUrl, manifestVersion, buildMode, events } = req.body || {}; + if (buildMode !== 'full') { + return res.status(400).json({ error: "Only buildMode 'full' may post a quarantine snapshot" }); + } + if (!Array.isArray(events)) { + return res.status(400).json({ error: "'events' must be an array" }); + } + const { QuarantineSnapshots, QuarantineEvents } = cds.entities(namespace); + const db = await cds.connect.to('db'); + const result = await db.tx(async (tx) => { + // Idempotency: drop any prior snapshot for this runId (and its events via composition cascade). + if (runId) { + const prior = await tx.run(SELECT.from(QuarantineSnapshots).columns('ID').where({ runId })); + if (prior.length) { + await tx.run(DELETE.from(QuarantineSnapshots).where({ ID: prior.map(p => p.ID) })); + } + } + // Flip the existing current snapshot off. + await tx.run(UPDATE(QuarantineSnapshots).set({ isCurrent: false }).where({ isCurrent: true })); + // Insert the new current snapshot + its events via deep insert. + const snapshotId = cds.utils.uuid(); + await tx.run(INSERT.into(QuarantineSnapshots).entries({ + ID: snapshotId, + runId: runId ? String(runId).slice(0, 60) : null, + workflowUrl: workflowUrl ? String(workflowUrl).slice(0, 400) : null, + manifestVersion: manifestVersion ? String(manifestVersion).slice(0, 60) : null, + buildMode: 'full', + isCurrent: true, + eventCount: events.length, + events: events.map(e => ({ + ID: cds.utils.uuid(), + slug: String(e.slug || '').toLowerCase().slice(0, 255), + sourceFile: e.sourceFile ? String(e.sourceFile).slice(0, 255) : null, + sourceRepo: e.sourceRepo ? String(e.sourceRepo).slice(0, 120) : null, + reason: String(e.reason || '').slice(0, 500), + sourceUrl: e.sourceUrl ? String(e.sourceUrl).slice(0, 600) : null, + })), + })); + return { snapshotId, eventCount: events.length }; + }); + LOG.warn(`[content/quarantine-events] recorded snapshot ${result.snapshotId} with ${result.eventCount} events (run ${runId || 'n/a'})`); + return res.status(201).json(result); + } catch (err) { + LOG.error(`[content/quarantine-events] ${err.message}`); + return res.status(500).json({ error: err.message }); + } + } +``` + +(Note: `LOG` is already in scope in this module — it's used by `pipelineLogFailureHandler` a few lines up. `SELECT`/`INSERT`/`UPDATE`/`DELETE` are CAP CQL globals.) + +- [ ] **Step 2: Add to the returned handler map** + +In the `return { ... }` object (~line 2157), add after `pipelineLogFailureHandler,`: + +```js + quarantineIngestHandler, +``` + +- [ ] **Step 3: Add the default re-export** + +After the `export const pipelineLogFailureHandler = _defaults.pipelineLogFailureHandler;` line (~2198): + +```js +export const quarantineIngestHandler = _defaults.quarantineIngestHandler; +``` + +- [ ] **Step 4: Re-export from the shim** + +In `srv/lib/content-store.js`, after line 29 (`export const pipelineLogFailureHandler = mod.pipelineLogFailureHandler;`): + +```js +export const quarantineIngestHandler = mod.quarantineIngestHandler; +``` + +- [ ] **Step 5: Verify imports resolve** + +Run: `node --input-type=module -e "import('./srv/lib/content-store.js').then(m => console.log(typeof m.quarantineIngestHandler))"` +Expected: prints `function`. (If it prints `undefined`, the workspace alias `@tutorials/content` resolved to a stale bundle — run `npm run build` of the content package or confirm the workspace link; for local dev the first `import('@tutorials/content/content-store.js')` branch should hit the edited source directly.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/content/content-store.js srv/lib/content-store.js +git commit -m "feat(#2585): quarantineIngestHandler for POST /content/quarantine-events" +``` + +--- + +## Task 4: Register route + handler tests + +**Files:** +- Modify: `srv/server.js` (line 34 import; add route near line 974 by `/content/pipeline-log`) +- Create: `srv/__tests__/lib/quarantine-ingest-routes.test.js` + +**Interfaces:** +- Consumes: `quarantineIngestHandler` + `contentAuthMiddleware` from `./lib/content-store.js` (Task 3). Test harness mirrors `srv/__tests__/lib/content-publish-routes.test.js` (`makeReq`/`makeRes`, `cds.test('serve', '--project', '.', '--in-memory')`, `CONTENT_API_KEY='test-key'`). +- Produces: live route `POST /content/quarantine-events`. + +- [ ] **Step 1: Write the failing test** + +Create `srv/__tests__/lib/quarantine-ingest-routes.test.js`: + +```js +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { quarantineIngestHandler } from '../../lib/content-store.js'; + +const NS = 'com.sap.developers.ims'; + +function makeReq(body = {}, headers = {}) { + return { body, headers, get(k) { return this.headers[k.toLowerCase()]; } }; +} +function makeRes() { + const res = { + _status: null, _body: null, _headers: {}, + status(code) { this._status = code; return this; }, + json(body) { this._body = body; return this; }, + setHeader(k, v) { this._headers[k] = v; } + }; + return res; +} + +cds.test('serve', '--project', '.', '--in-memory'); + +describe('quarantine ingest route', () => { + beforeAll(async () => { await cds.connect.to('db'); }); + + beforeEach(async () => { + const { QuarantineSnapshots, QuarantineEvents } = cds.entities(NS); + await DELETE.from(QuarantineEvents); + await DELETE.from(QuarantineSnapshots); + }); + + it('rejects non-full buildMode with 400', async () => { + const res = makeRes(); + await quarantineIngestHandler(makeReq({ buildMode: 'slug', events: [] }), res); + expect(res._status).toBe(400); + }); + + it('rejects non-array events with 400', async () => { + const res = makeRes(); + await quarantineIngestHandler(makeReq({ buildMode: 'full', events: 'nope' }), res); + expect(res._status).toBe(400); + }); + + it('inserts a current snapshot + events and flips prior current off', async () => { + const { QuarantineSnapshots } = cds.entities(NS); + + const res1 = makeRes(); + await quarantineIngestHandler(makeReq({ + runId: 'run-1', workflowUrl: 'https://x/runs/run-1', buildMode: 'full', + events: [{ slug: 'Foo-Bar', sourceFile: 'foo.md', sourceRepo: 'repo-a', reason: 'empty' }] + }), res1); + expect(res1._status).toBe(201); + expect(res1._body.eventCount).toBe(1); + + const res2 = makeRes(); + await quarantineIngestHandler(makeReq({ + runId: 'run-2', buildMode: 'full', + events: [{ slug: 'baz', reason: 'bad stepCount' }] + }), res2); + expect(res2._status).toBe(201); + + const currents = await SELECT.from(QuarantineSnapshots).where({ isCurrent: true }); + expect(currents.length).toBe(1); + expect(currents[0].runId).toBe('run-2'); + }); + + it('lowercases slugs on insert', async () => { + const { QuarantineEvents } = cds.entities(NS); + await quarantineIngestHandler(makeReq({ + runId: 'r', buildMode: 'full', + events: [{ slug: 'MixedCase-Slug', reason: 'x' }] + }), makeRes()); + const rows = await SELECT.from(QuarantineEvents); + expect(rows.map(r => r.slug)).toContain('mixedcase-slug'); + }); + + it('is idempotent per runId (repeat replaces, no double-count)', async () => { + const { QuarantineSnapshots } = cds.entities(NS); + const body = { runId: 'dup', buildMode: 'full', events: [{ slug: 'a', reason: 'x' }] }; + await quarantineIngestHandler(makeReq(body), makeRes()); + await quarantineIngestHandler(makeReq(body), makeRes()); + const snaps = await SELECT.from(QuarantineSnapshots).where({ runId: 'dup' }); + expect(snaps.length).toBe(1); + }); + + it('empty events on full build writes an empty current snapshot (auto-clear)', async () => { + const { QuarantineSnapshots } = cds.entities(NS); + await quarantineIngestHandler(makeReq({ runId: 'clean', buildMode: 'full', events: [] }), makeRes()); + const cur = await SELECT.from(QuarantineSnapshots).where({ isCurrent: true }); + expect(cur.length).toBe(1); + expect(cur[0].eventCount).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run srv/__tests__/lib/quarantine-ingest-routes.test.js` +Expected: FAIL — either `quarantineIngestHandler is not a function` (if Task 3 export missing) or table-not-found (if Task 1/2 not loaded). This confirms the test exercises the new code. + +- [ ] **Step 3: Register the route in `srv/server.js`** + +Add `quarantineIngestHandler` to the import on line 34 (append before the closing `}`): + +```js +, quarantineIngestHandler } from './lib/content-store.js'; +``` + +Add the route next to `/content/pipeline-log` (after line 974): + +```js + // #2585 — CI full-rebuild quarantine snapshot. Same auth as /content/publish. + app.post('/content/quarantine-events', express.json({ limit: '2mb' }), contentAuthMiddleware, quarantineIngestHandler); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run srv/__tests__/lib/quarantine-ingest-routes.test.js` +Expected: PASS (all 6 tests). The route registration itself isn't exercised by these unit tests (they call the handler directly, matching the sibling test), so a green run here proves the handler + schema; the route wiring is a one-line mirror of the verified `pipeline-log` pattern. + +- [ ] **Step 5: Commit** + +```bash +git add srv/server.js srv/__tests__/lib/quarantine-ingest-routes.test.js +git commit -m "feat(#2585): register POST /content/quarantine-events + handler tests" +``` + +--- + +## Task 5: Validator posts the snapshot — `scripts/validate-tutorials.ts` + +**Files:** +- Modify: `scripts/validate-tutorials.ts` +- Create: `test/validate-tutorials-quarantine-post.test.ts` + +**Interfaces:** +- Consumes: the existing `quarantined` array (now carrying `slug` + `sourceRepo`), `repoBySlug` map (already loaded via `loadRepoBySlug()`), env `CAP_BASE_URL`, `CONTENT_API_KEY`, `RUN_ID`, `WORKFLOW_URL`, `QUARANTINE_REPORT`. +- Produces: exported `async function postQuarantineSnapshot(events, env): Promise` where `events: Array<{ slug, sourceFile, sourceRepo, reason, sourceUrl? }>` and `env: { base?, key?, runId?, workflowUrl?, report? }`. It POSTs `{ runId, workflowUrl, buildMode:'full', events }` and NEVER throws / never exits non-zero. + +- [ ] **Step 1: Write the failing test** + +Create `test/validate-tutorials-quarantine-post.test.ts`: + +```ts +import { describe, it, expect, vi, afterEach } from 'vitest' +import { postQuarantineSnapshot } from '../scripts/validate-tutorials' + +const events = [{ slug: 'a', sourceFile: 'a.md', sourceRepo: 'r', reason: 'x' }] + +afterEach(() => { vi.restoreAllMocks() }) + +describe('postQuarantineSnapshot (fail-open guard)', () => { + it('no-ops when report !== full', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await postQuarantineSnapshot(events, { base: 'http://x', key: 'k', report: '' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('no-ops when base or key missing', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await postQuarantineSnapshot(events, { base: '', key: 'k', report: 'full' }) + await postQuarantineSnapshot(events, { base: 'http://x', key: '', report: 'full' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('posts with Bearer auth when fully configured', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ snapshotId: 's', eventCount: 1 }), { status: 201 }) as any + ) + await postQuarantineSnapshot(events, { + base: 'http://x/', key: 'k', runId: '42', workflowUrl: 'http://x/runs/42', report: 'full' + }) + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, init] = fetchSpy.mock.calls[0] + expect(url).toBe('http://x/content/quarantine-events') + expect((init as any).headers.Authorization).toBe('Bearer k') + const body = JSON.parse((init as any).body) + expect(body.buildMode).toBe('full') + expect(body.events).toHaveLength(1) + }) + + it('never throws when fetch rejects (fail-open)', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) + await expect( + postQuarantineSnapshot(events, { base: 'http://x', key: 'k', report: 'full' }) + ).resolves.toBeUndefined() + }) + + it('never throws when server returns non-ok (fail-open)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 500 }) as any) + await expect( + postQuarantineSnapshot(events, { base: 'http://x', key: 'k', report: 'full' }) + ).resolves.toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run test/validate-tutorials-quarantine-post.test.ts` +Expected: FAIL — `postQuarantineSnapshot is not exported` / import error. + +- [ ] **Step 3: Add the exported function + wire it into the script** + +In `scripts/validate-tutorials.ts`, add near the other exported helpers (after `emptyContentCheck`): + +```ts +/** + * Best-effort POST of the full-rebuild quarantine snapshot to the CAP backend + * (#2585). FAIL-OPEN by contract: a missing config, a rejected fetch, or a + * non-ok response is logged and swallowed — this must NEVER throw or make the + * validator exit non-zero, and must NOT fire from the mta.yaml before-all build + * or local runs (no backend), which is why it no-ops unless report === 'full' + * AND both base + key are present. + */ +export async function postQuarantineSnapshot( + events: Array<{ slug: string; sourceFile: string; sourceRepo?: string; reason: string; sourceUrl?: string }>, + env: { base?: string; key?: string; runId?: string; workflowUrl?: string; report?: string }, +): Promise { + if (env.report !== 'full' || !env.base || !env.key) return + const url = `${env.base.replace(/\/$/, '')}/content/quarantine-events` + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.key}`, + 'Content-Type': 'application/json', + 'x-initiator': env.runId ? `ci/${env.runId}` : 'ci', + }, + body: JSON.stringify({ + runId: env.runId, + workflowUrl: env.workflowUrl, + buildMode: 'full', + events, + }), + }) + if (!resp.ok) { + console.warn(` ⚠ quarantine snapshot POST returned ${resp.status} — continuing (fail-open)`) + } else { + console.log(` → quarantine snapshot posted (${events.length} events)`) + } + } catch (e: any) { + console.warn(` ⚠ quarantine snapshot POST failed: ${e?.message ?? e} — continuing (fail-open)`) + } +} +``` + +Change the `quarantined` collection to carry `slug` + `sourceRepo`. Replace the declaration (currently `const quarantined: Array<{ file: string; reason: string }> = []`) with: + +```ts +const quarantined: Array<{ file: string; slug: string; sourceRepo?: string; reason: string }> = [] +``` + +In the loop, where it currently does `quarantined.push({ file, reason })`, capture the slug/repo. The frontmatter is already parsed as `fm` inside the `try`; but `reason` can also be set before/outside parse (empty-content / parse error) where `fm` is unavailable. So compute slug defensively from the filename when frontmatter is absent: + +```ts + if (reason) { + const slug = String((typeof fm !== 'undefined' && fm?.slug) ? fm.slug : file.replace(/\.md$/, '')).toLowerCase() + quarantined.push({ file, slug, sourceRepo: repoBySlug[slug], reason }) + mkdirSync(QUARANTINE_DIR, { recursive: true }) + renameSync(join(TUTORIALS_DIR, file), join(QUARANTINE_DIR, file)) + console.log(` ✗ ${file}: ${reason}`) + } +``` + +> Note: `fm` is currently scoped inside the `try` block. Hoist its declaration to the top of the loop body (`let fm: any`) so it's in scope at the push site. Assign `fm = matter(content).data` inside the try as today. + +At the very end of the script (after the summary logs), add the best-effort post — it runs for a full build even when `quarantined` is empty (empty snapshot = auto-clear): + +```ts +await postQuarantineSnapshot( + quarantined.map(q => ({ slug: q.slug, sourceFile: q.file, sourceRepo: q.sourceRepo, reason: q.reason })), + { + base: process.env.CAP_BASE_URL, + key: process.env.CONTENT_API_KEY, + runId: process.env.RUN_ID, + workflowUrl: process.env.WORKFLOW_URL, + report: process.env.QUARANTINE_REPORT, + }, +) +``` + +> The file currently runs top-level synchronous code (no `main()`); a trailing top-level `await` requires the module to be ESM (it is — it uses `import`/`import.meta.url`), so top-level await is valid under the project's `tsx` runner. If `tsx` complains, wrap the whole script tail in an `async function main(){…}; await main()` — but try the top-level await first. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run test/validate-tutorials-quarantine-post.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Verify existing validator tests still pass** + +Run: `npx vitest run test/validate-tutorials-shortcode.test.ts` +Expected: PASS — the `stepCountReason` / `shortcodeBalanceCheck` / `emptyContentCheck` exports are unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/validate-tutorials.ts test/validate-tutorials-quarantine-post.test.ts +git commit -m "feat(#2585): validate-tutorials posts full-build quarantine snapshot (fail-open)" +``` + +--- + +## Task 6: Admin UI list view + +**Files:** +- Create: `app/admin/quarantine-events/webapp/manifest.json` +- Create: `app/admin/quarantine-events/webapp/Component.js` +- Create: `app/admin/quarantine-events/webapp/i18n/i18n.properties` +- Modify: `app/admin-annotations.cds` (add `@UI` for `AdminService.QuarantineEventsCurrent`) + +**Interfaces:** +- Consumes: `AdminService.QuarantineEventsCurrent` (Task 2) over `/admin/`. +- Produces: a discoverable admin component with app id `sap.tutorials.admin.quarantineEvents`, deep-linkable at `/admin-ui/#quarantineEventsCurrent-manage`, listing current quarantined slugs. + +- [ ] **Step 1: Add Fiori annotations** in `app/admin-annotations.cds` (template: the `StepFailures` block): + +```cds +annotate AdminService.QuarantineEventsCurrent with { + slug @Common.Label: 'Slug'; + reason @Common.Label: 'Reason'; + sourceRepo @Common.Label: 'Source Repo'; + sourceFile @Common.Label: 'Source File'; + sourceUrl @Common.Label: 'Source URL'; + buildAt @Common.Label: 'Last Seen Build'; + runId @Common.Label: 'Run'; +}; +annotate AdminService.QuarantineEventsCurrent with @( + UI: { + HeaderInfo: { TypeName: 'Quarantined Tutorial', TypeNamePlural: 'Quarantined Tutorials', Title: { Value: slug } }, + SelectionFields: [ sourceRepo, buildAt ], + LineItem: [ + { Value: slug }, + { Value: reason }, + { Value: sourceRepo }, + { Value: sourceFile }, + { Value: buildAt }, + { $Type: 'UI.DataFieldWithUrl', Value: sourceUrl, Url: sourceUrl, Label: 'Source' } + ] + }, + Capabilities.DeleteRestrictions.Deletable: false, + Capabilities.InsertRestrictions.Insertable: false, + Capabilities.UpdateRestrictions.Updatable: false +); +``` + +- [ ] **Step 2: Create `Component.js`** (template: `app/admin/channels/webapp/Component.js`): + +```js +sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) { + "use strict"; + return AppComponent.extend("sap.tutorials.admin.quarantineEvents.Component", { + metadata: { manifest: "json" } + }); +}); +``` + +- [ ] **Step 3: Create `manifest.json`** (copy `app/admin/channels/webapp/manifest.json`, change ids/entity). Key values — `sap.app.id = sap.tutorials.admin.quarantineEvents`, dataSource `mainService` uri `/admin/`, ListReport `contextPath: /QuarantineEventsCurrent`, ObjectPage on the same, `crossNavigation.inbounds` key `QuarantineEventsCurrent-manage`: + +```json +{ + "_version": "1.59.0", + "sap.app": { + "id": "sap.tutorials.admin.quarantineEvents", + "type": "application", + "title": "{{appTitle}}", + "dataSources": { + "mainService": { + "uri": "/admin/", + "type": "OData", + "settings": { "odataVersion": "4.0" } + } + }, + "crossNavigation": { + "inbounds": { + "QuarantineEventsCurrent-manage": { + "semanticObject": "QuarantineEventsCurrent", + "action": "manage", + "title": "{{appTitle}}", + "signature": { "parameters": {}, "additionalParameters": "allowed" } + } + } + } + }, + "sap.ui": { "technology": "UI5", "deviceTypes": { "desktop": true, "tablet": true, "phone": true } }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.120.0", + "libs": { "sap.fe.templates": {} } + }, + "models": { + "i18n": { "type": "sap.ui.model.resource.ResourceModel", "settings": { "bundleName": "sap.tutorials.admin.quarantineEvents.i18n.i18n" } }, + "": { + "dataSource": "mainService", + "settings": { "synchronizationMode": "None", "operationMode": "Server", "autoExpandSelect": true, "earlyRequests": true } + } + }, + "routing": { + "routes": [ + { "pattern": ":?query:", "name": "QuarantineEventsCurrentList", "target": "QuarantineEventsCurrentList" }, + { "pattern": "QuarantineEventsCurrent({key}):?query:", "name": "QuarantineEventsCurrentObjectPage", "target": "QuarantineEventsCurrentObjectPage" } + ], + "targets": { + "QuarantineEventsCurrentList": { + "type": "Component", "id": "QuarantineEventsCurrentList", "name": "sap.fe.templates.ListReport", + "options": { "settings": { "contextPath": "/QuarantineEventsCurrent", "variantManagement": "Page", "initialLoad": true } } + }, + "QuarantineEventsCurrentObjectPage": { + "type": "Component", "id": "QuarantineEventsCurrentObjectPage", "name": "sap.fe.templates.ObjectPage", + "options": { "settings": { "contextPath": "/QuarantineEventsCurrent" } } + } + } + } + } +} +``` + +> Before writing, open `app/admin/channels/webapp/manifest.json` and match its exact `_version`, `minUI5Version`, and any project-specific blocks (e.g. `sap.fe` config). Prefer the real template's values over the ones above where they differ. + +- [ ] **Step 4: Create `i18n/i18n.properties`:** + +```properties +appTitle=Quarantined Tutorials +appDescription=Tutorials dropped from the active content snapshot by pre-publish validation +``` + +- [ ] **Step 5: Verify auto-discovery picks it up** + +Run: `node app/admin-shell/scripts/discover-admin-components.js` +Expected: output lists `sap.tutorials.admin.quarantineEvents` among discovered components (exact invocation may be via an npm script — check `app/admin-shell/package.json`; if it's `generate-manifest.js`, run that and confirm the generated shell manifest now contains a `quarantineEvents` componentUsage). No "app id mismatch" error. + +- [ ] **Step 6: Verify the CDS model still compiles with the new annotations** + +Run: `npx cds compile srv 2>&1 | grep -iE "error|quarantine" | head` +Expected: no errors referencing `QuarantineEventsCurrent`. + +- [ ] **Step 7: Commit** + +```bash +git add app/admin/quarantine-events app/admin-annotations.cds +git commit -m "feat(#2585): Admin UI list view for current quarantined tutorials" +``` + +--- + +## Task 7: Workflow env wiring — `.github/workflows/rebuild-content.yml` + +**Files:** +- Modify: `.github/workflows/rebuild-content.yml` ("Validate tutorials" step, line ~631-633) + +**Interfaces:** +- Consumes: `steps.srv.outputs.srv_url`, `secrets.CONTENT_API_KEY`, `github.run_id`, `github.server_url`, `github.repository`, `steps.mode.outputs.effective_mode` (all confirmed present in the current file). +- Produces: the validator receives `CAP_BASE_URL`, `CONTENT_API_KEY`, `RUN_ID`, `WORKFLOW_URL`, and `QUARANTINE_REPORT=full` only on a full build. + +- [ ] **Step 1: Add the `env:` block to the validate step** + +Replace lines ~631-633: + +```yaml + - name: Validate tutorials + if: ${{ steps.mode.outputs.effective_mode != 'catalog-only' }} + run: npm run validate-tutorials +``` + +with: + +```yaml + - name: Validate tutorials + if: ${{ steps.mode.outputs.effective_mode != 'catalog-only' }} + env: + CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} + CONTENT_API_KEY: ${{ secrets.CONTENT_API_KEY }} + RUN_ID: ${{ github.run_id }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + QUARANTINE_REPORT: ${{ steps.mode.outputs.effective_mode == 'full' && 'full' || '' }} + run: npm run validate-tutorials +``` + +(Match the surrounding indentation exactly — the file uses 6-space step indentation under `steps:`. `CONTENT_API_KEY` uses the plain secret, matching the existing fetch step at line 470 — there is no `_QA` variant for this secret in this workflow.) + +- [ ] **Step 2: Validate the workflow YAML** + +Run: `npx yaml-lint .github/workflows/rebuild-content.yml 2>/dev/null || yq '.' .github/workflows/rebuild-content.yml > /dev/null && echo "valid YAML"` +Expected: `valid YAML` (or the actual repo lint command if one exists — check `package.json` scripts for a workflow lint). Confirm the `env:` keys are nested under the step, not the job. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/rebuild-content.yml +git commit -m "feat(#2585): wire quarantine-snapshot env into rebuild-content validate step" +``` + +--- + +## Task 8: Route-drift guard check + full test sweep + +**Files:** +- Possibly modify: `scripts/check-srv-qa-route-drift.ts` (allowlist, only if the guard flags the new route) + +**Interfaces:** +- Consumes: nothing new. +- Produces: a passing route-drift guard and a green unit-test run. + +- [ ] **Step 1: Run the route-drift guard** + +Run: `npx tsx scripts/check-srv-qa-route-drift.ts` (confirm the exact entrypoint — it's referenced near `srv/server.js:962`; it may be invoked via an npm script or at server boot). +Expected: PASS. If it fails complaining that `/content/quarantine-events` exists on srv but not srv-qa (or vice versa), add it to the guard's allowlist following the existing entries for other `/content/*` CI routes, then re-run. + +- [ ] **Step 2: Run the full unit suite** + +Run: `scripts/quiet-run.sh npm test` +Expected: PASS. (Wrapped in `quiet-run.sh` per repo policy — do not paste raw scrollback. Drill into `.quiet-logs/` only if it fails.) If any pre-existing unrelated test is flaky, note it but do not fix it here. + +- [ ] **Step 3: Lint the CDS** + +Run: `npx cds lint . 2>&1 | tail -20` +Expected: no new errors attributable to the quarantine files. + +- [ ] **Step 4: Commit any guard change** + +```bash +git add scripts/check-srv-qa-route-drift.ts +git commit -m "chore(#2585): allowlist /content/quarantine-events in route-drift guard" +``` + +(Skip this commit if Step 1 passed with no change.) + +--- + +## Task 9: Open the PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin worktree-quarantine-2585 +``` + +- [ ] **Step 2: Open a draft PR targeting DEV** + +```bash +gh pr create --repo sap-tutorials/tutorials-ims --base DEV --draft \ + --title "feat(#2585): persist tutorial quarantine results + Admin UI view" \ + --body "Closes #2585. Full-rebuild quarantine sets now persist to HANA (QuarantineSnapshots + QuarantineEvents) via POST /content/quarantine-events and surface in the Admin UI (current set, auto-cleared by the next clean full build). Snapshot-per-build model; DB + Admin view only (no alerting); validator posts fail-open. See docs/superpowers/specs/2026-10-04-tutorial-quarantine-persistence-design.md." +``` + +- [ ] **Step 3: Report the PR URL to the user.** + +--- + +## Self-Review + +**1. Spec coverage:** +- New persistence entity capturing slug/reason/sourceRepo/manifestVersion/runId/workflowUrl/createdAt → Task 1 (`managed` gives `createdAt`). ✓ +- Validator writes rows to HANA via authenticated endpoint instead of throwaway errors.json → Tasks 3-5 (errors.json is kept *in addition*, per the "or in addition to" wording). ✓ +- Admin UI view: current quarantined tutorials, reason + source repo + last-seen build, sortable/filterable, link to source file → Task 6 (`QuarantineEventsCurrent`, LineItem, SelectionFields, DataFieldWithUrl). Fiori Elements LRs are sortable/filterable by default. ✓ +- Clean slug aged-out → snapshot-per-build: next clean full build writes a snapshot without the slug → `QuarantineEventsCurrent` no longer shows it; empty-events case tested (Task 4 Step 1). ✓ +- Reporting count over time / per repo → raw `QuarantineSnapshots` + `QuarantineEvents` projections (Task 2) with `createdAt`/`sourceRepo` support ad-hoc reporting; AnalyticsService SQL can group over them. ✓ + +**2. Placeholder scan:** No TBD/TODO; every code step has real content. The two "match the real template's values" notes (manifest `_version`, `discover` entrypoint) are verification instructions with a concrete fallback, not placeholders. + +**3. Type consistency:** `quarantineIngestHandler(req,res)` named identically across Tasks 3/4; `postQuarantineSnapshot(events, env)` signature identical across Task 5 test + impl; entity/field names (`QuarantineSnapshots`, `QuarantineEvents`, `QuarantineEventsCurrent`, `isCurrent`, `eventCount`, `runId`, `buildAt`) consistent across Tasks 1/2/4/6. `buildAt` is the projection alias for `snapshot.createdAt` (Task 2) and is the field annotated/listed in Task 6. ✓ + +**Residual risks flagged for the executor (verify, don't assume):** +- `cds.utils.uuid()` + deep-insert of a composition with explicit child `ID`s — if the CAP version rejects explicit composition keys on insert, drop the child `ID` and let CAP generate them. +- The `where snapshot.isCurrent = true` projection must compile as a view on the current compiler; if it rejects the association path in a `where`, fall back to exposing `QuarantineEvents` + a bound filter in the manifest (spec option (b)). +- `discover-admin-components.js` exact invocation (direct node vs npm script) — confirm from `app/admin-shell/package.json`. +``` \ No newline at end of file From 3db4a2f81d5f935e6774997eb3e077c2f6114849 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:33:37 -0400 Subject: [PATCH 24/37] build(cds): regenerate staged csn for Users.pictureUrl (#2628) --- db/last-dev/csn.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index a68db70c6..b3aed4733 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -97,6 +97,11 @@ "type": "cds.Timestamp", "@cds.persistence.name": "KHOROSLINKEDAT" }, + "pictureUrl": { + "type": "cds.String", + "length": 1024, + "@cds.persistence.name": "PICTUREURL" + }, "githubLogin": { "type": "cds.String", "length": 64, From 7422e4b9b0f3cd1e7f609c2f4773874ae8677cd0 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:10:51 -0400 Subject: [PATCH 25/37] feat(a2a): add IAS+xsuaa hybrid auth to main srv, bind IAS client (#2593) --- .deploy/mta.yaml | 8 ++++++++ mta.yaml | 8 ++++++++ package.json | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 704a2d421..8ec4daa15 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -160,6 +160,7 @@ modules: requires: - name: tutorials-hana - name: tutorials-xsuaa + - name: tutorials-identity # IAS hybrid auth — validates IAS tokens alongside XSUAA (#2593) - name: tutorials-destination - name: tutorials-audit-log - name: tutorials-cloud-logging @@ -488,6 +489,13 @@ resources: parameters: service-name: tutorials-approuter-identity + # IAS identity service instance — adopted as existing-service (created out-of-band). + # Used as the IAS client for hybrid auth on tutorials-srv (IAS+xsuaa, #2593). + - name: tutorials-identity + type: org.cloudfoundry.existing-service + parameters: + service-name: tutorials-identity + - name: tutorials-destination type: org.cloudfoundry.managed-service parameters: diff --git a/mta.yaml b/mta.yaml index 1fedc3002..32e0e1fa2 100644 --- a/mta.yaml +++ b/mta.yaml @@ -173,6 +173,7 @@ modules: requires: - name: tutorials-hana - name: tutorials-xsuaa + - name: tutorials-identity # IAS hybrid auth — validates IAS tokens alongside XSUAA (#2593) - name: tutorials-destination - name: tutorials-mail - name: tutorials-audit-log @@ -384,6 +385,13 @@ resources: service-name: tutorials-xsuaa path: ./xs-security.json + # IAS identity service instance — adopted as existing-service (created out-of-band). + # Used as the IAS client for hybrid auth on tutorials-srv (IAS+xsuaa, #2593). + - name: tutorials-identity + type: org.cloudfoundry.existing-service + parameters: + service-name: tutorials-identity + - name: tutorials-destination type: org.cloudfoundry.managed-service parameters: diff --git a/package.json b/package.json index c1dff5896..a68daf732 100644 --- a/package.json +++ b/package.json @@ -242,6 +242,10 @@ } }, "requires": { + "auth": { + "[hybrid]": { "kind": "ias", "xsuaa": true }, + "[production]": { "kind": "ias", "xsuaa": true } + }, "AICore": { "resourceGroup": "default", "[hybrid]": { From 9e064fbe613f18454bf37d5435cbb39eab1b3c0b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:34:29 -0400 Subject: [PATCH 26/37] feat(#2586): surface rebuild quarantine outcome on Tutorials OP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin "Rebuild this tutorial" dispatches a fire-and-forget GitHub workflow_dispatch, so the bound action returns {dispatched:true} immediately — even when the rebuild later quarantines the tutorial at pre-publish validation and never publishes it. The UI showed "success" and the real outcome was lost. Build on #2585's central quarantine store (QuarantineEventsCurrent = events whose parent snapshot isCurrent) to make the outcome honest: - Add Tutorials.quarantineCurrent association (slug -> QuarantineEventsCurrent) so the OP can read the live quarantine set for the row. - New "Active Quarantine" ReferenceFacet (first section) bound to a slug-scoped @UI.LineItem#OnTutorial variant: reason + build time + links to the Actions run and source .md. Empty for healthy tutorials. - Reword the rebuild dialog + success message: dispatch only QUEUES the rebuild; a healthy result shows no quarantine rows, a failed one lists the reason. Replace the transient success toast with a persistent MessageBox.information and refresh the OP context on dispatch. Tests: - srv/__tests__/admin-service-quarantine-facet.test.js — reads a Tutorial through AdminService with $expand=quarantineCurrent; asserts the current quarantine row (reason/workflowUrl/buildAt) for a dropped slug and an empty expand for a healthy one. - test/e2e/admin-tutorials-quarantine-facet.test.js — post-deploy e2e (self-skips without SMOKE_BASE_URL): OP read carries $expand=quarantineCurrent and the "Active Quarantine" section renders. --- app/admin-annotations.cds | 18 ++++ .../ext/SourceMarkdownHandler.controller.js | 17 ++- .../tutorials/webapp/i18n/i18n.properties | 6 +- .../admin-service-quarantine-facet.test.js | 82 ++++++++++++++ srv/admin-service.cds | 9 ++ .../admin-tutorials-quarantine-facet.test.js | 101 ++++++++++++++++++ 6 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 srv/__tests__/admin-service-quarantine-facet.test.js create mode 100644 test/e2e/admin-tutorials-quarantine-facet.test.js diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 5e3e2b396..3b27657d1 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -975,6 +975,13 @@ annotate AdminService.AuthorAiRequests with @UI: { // in the same block further below. annotate AdminService.Tutorials with @UI: { Facets: [ + // #2586 — active-quarantine section. Empty for a healthy tutorial; a row + // appears only when the most recent rebuild DROPPED this slug at pre-publish + // validation instead of publishing it (the "silent success" the Admin + // Rebuild button used to hide). Placed first so an operator who rebuilt a + // tutorial sees the real outcome at the top of its page. + { $Type: 'UI.ReferenceFacet', ID: 'QuarantineFacet', Label: 'Active Quarantine', + Target: 'quarantineCurrent/@UI.LineItem#OnTutorial' }, { $Type: 'UI.ReferenceFacet', ID: 'General', Label: 'General', Target: '@UI.FieldGroup#General' }, { $Type: 'UI.ReferenceFacet', ID: 'Lifecycle', Label: 'Lifecycle', Target: '@UI.FieldGroup#Lifecycle' }, { $Type: 'UI.ReferenceFacet', Label: 'Categories', ID: 'CategoriesFacet', Target: 'categories/@UI.LineItem' }, @@ -4709,8 +4716,19 @@ annotate AdminService.QuarantineEventsCurrent with { sourceUrl @Common.Label: 'Source URL'; buildAt @Common.Label: 'Last Seen Build'; runId @Common.Label: 'Run'; + workflowUrl @Common.Label: 'Workflow Run'; }; +// #2586 — slug-scoped variant for the Tutorials Object Page "Active Quarantine" +// facet. Omits the slug column (redundant inside one tutorial's page) and leads +// with the reason + build time + a link to the Actions run that dropped it. +annotate AdminService.QuarantineEventsCurrent with @UI.LineItem #OnTutorial: [ + { Value: reason, Label: 'Reason' }, + { Value: buildAt, Label: 'Build Time' }, + { $Type: 'UI.DataFieldWithUrl', Value: workflowUrl, Url: workflowUrl, Label: 'Workflow Run' }, + { $Type: 'UI.DataFieldWithUrl', Value: sourceUrl, Url: sourceUrl, Label: 'Source' } +]; + annotate AdminService.QuarantineEventsCurrent with @( UI: { HeaderInfo: { diff --git a/app/admin/tutorials/webapp/ext/SourceMarkdownHandler.controller.js b/app/admin/tutorials/webapp/ext/SourceMarkdownHandler.controller.js index f9c74b896..0bd7f5f0f 100644 --- a/app/admin/tutorials/webapp/ext/SourceMarkdownHandler.controller.js +++ b/app/admin/tutorials/webapp/ext/SourceMarkdownHandler.controller.js @@ -1,9 +1,8 @@ sap.ui.define([ "sap/ui/core/mvc/ControllerExtension", "sap/ui/model/json/JSONModel", - "sap/m/MessageBox", - "sap/m/MessageToast" -], function (ControllerExtension, JSONModel, MessageBox, MessageToast) { + "sap/m/MessageBox" +], function (ControllerExtension, JSONModel, MessageBox) { "use strict"; // Hand-rolled markdown → HTML converter for the rendered preview tab. @@ -206,10 +205,18 @@ sap.ui.define([ ); oAction.execute().then(function () { - MessageToast.show(sToastSuccess, { duration: 5000 }); + // Dispatch accepted != rebuild done. The bound action returns + // {dispatched:true} the moment the GitHub workflow_dispatch is + // queued (fire-and-forget, srv/lib/rebuild-trigger.js) — long + // before validation/publish runs. Show a persistent information + // dialog (not a transient toast) so the operator reads that the + // real outcome lands in the "Active Quarantine" section (#2586), + // and refresh the context so an already-current quarantine shows. + try { oContext.refresh(); } catch (e) { /* best-effort */ } + MessageBox.information(sToastSuccess, { title: sDialogTitle }); }).catch(function (err) { var msg = (err && err.message) ? err.message : String(err); - MessageBox.error(sToastError + msg); + MessageBox.error(sToastError + " " + msg); }); } }); diff --git a/app/admin/tutorials/webapp/i18n/i18n.properties b/app/admin/tutorials/webapp/i18n/i18n.properties index 9cd9004c6..4aa181557 100644 --- a/app/admin/tutorials/webapp/i18n/i18n.properties +++ b/app/admin/tutorials/webapp/i18n/i18n.properties @@ -4,6 +4,6 @@ # Rebuild action button (admin-ui rebuild-button feature) RebuildTutorialButton=Rebuild this tutorial RebuildTutorialDialogTitle=Rebuild tutorial -RebuildTutorialDialogMessage=Rebuild tutorial "{0}"? This dispatches a workflow that will republish the tutorial's content to HANA in about 2 minutes. -RebuildTutorialToastSuccess=Rebuild dispatched for "{0}". The page will refresh in ~2 minutes. -RebuildTutorialToastError=Could not dispatch rebuild: +RebuildTutorialDialogMessage=Rebuild tutorial "{0}"? This dispatches a workflow that re-validates and republishes the tutorial (about 2 minutes). Dispatch only queues the rebuild — if validation fails, the tutorial is quarantined and NOT published. Check the "Active Quarantine" section afterwards for the real outcome. +RebuildTutorialToastSuccess=Rebuild dispatched for "{0}" — this only means the workflow was queued, not that publishing succeeded. In ~2 minutes, reload this page: a healthy rebuild shows no "Active Quarantine" rows; a failed one lists the quarantine reason there. +RebuildTutorialToastError=Could not dispatch rebuild: diff --git a/srv/__tests__/admin-service-quarantine-facet.test.js b/srv/__tests__/admin-service-quarantine-facet.test.js new file mode 100644 index 000000000..f1c6b72c2 --- /dev/null +++ b/srv/__tests__/admin-service-quarantine-facet.test.js @@ -0,0 +1,82 @@ +// srv/__tests__/admin-service-quarantine-facet.test.js +// +// #2586 — the Tutorials Object Page surfaces an "Active Quarantine" facet so a +// force-rebuild that DROPPED a tutorial at pre-publish validation no longer +// looks like a silent success. The facet binds to the Tutorials.quarantineCurrent +// association (slug → AdminService.QuarantineEventsCurrent, the #2585 live set). +// +// This tests the ACTUAL service surface: read a Tutorial through AdminService +// with $expand=quarantineCurrent and assert the current quarantine row (reason + +// workflowUrl + buildAt) comes back, and that a healthy tutorial expands empty. +// Seeds QuarantineSnapshots/QuarantineEvents the same way as +// srv/__tests__/lib/rebuild-outcome.test.js. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import cds from '@sap/cds'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); +const auth = { auth: { username: 'admin', password: 'admin' }, validateStatus: () => true }; + +const NS = 'com.sap.developers.ims'; + +const QUARANTINED_SLUG = 'btp-transport-management-cpi-01-use-case'; +const HEALTHY_SLUG = 'a-perfectly-fine-tutorial'; + +describe('Tutorials Object Page — Active Quarantine facet (#2586)', () => { + beforeAll(async () => { + await cds.connect.to('db'); + const { Tutorials, QuarantineSnapshots, QuarantineEvents } = cds.entities(NS); + + await DELETE.from(QuarantineEvents); + await DELETE.from(QuarantineSnapshots); + await DELETE.from(Tutorials).where({ ID: { in: ['tut-q', 'tut-ok'] } }); + + await INSERT.into(Tutorials).entries([ + { ID: 'tut-q', title: 'Quarantined one', slug: QUARANTINED_SLUG }, + { ID: 'tut-ok', title: 'Healthy one', slug: HEALTHY_SLUG }, + ]); + + // Current snapshot drops the quarantined slug; healthy slug is absent from it. + await INSERT.into(QuarantineSnapshots).entries([ + { ID: 'snap-cur', runId: '100', workflowUrl: 'https://gh/runs/100', + buildMode: 'full', isCurrent: true, eventCount: 1, + createdAt: '2026-10-04T10:00:00Z' }, + ]); + await INSERT.into(QuarantineEvents).entries([ + { ID: 'ev-1', snapshot_ID: 'snap-cur', slug: QUARANTINED_SLUG, + reason: 'missing required frontmatter field: stepCount', + sourceUrl: 'https://github.com/sap-tutorials/foo/blob/main/x.md' }, + ]); + }); + + afterAll(async () => { + const { Tutorials, QuarantineSnapshots, QuarantineEvents } = cds.entities(NS); + await DELETE.from(QuarantineEvents); + await DELETE.from(QuarantineSnapshots); + await DELETE.from(Tutorials).where({ ID: { in: ['tut-q', 'tut-ok'] } }); + }); + + it('expands the current quarantine row (reason + workflowUrl + buildAt) for a dropped tutorial', async () => { + const { data, status } = await project.get( + `/admin/Tutorials(ID=tut-q,IsActiveEntity=true)?$expand=quarantineCurrent`, + auth, + ); + expect(status).toBe(200); + const rows = data.quarantineCurrent; + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(1); + expect(rows[0].reason).toBe('missing required frontmatter field: stepCount'); + expect(rows[0].workflowUrl).toBe('https://gh/runs/100'); + expect(rows[0].sourceUrl).toBe('https://github.com/sap-tutorials/foo/blob/main/x.md'); + expect(rows[0].buildAt).toBeTruthy(); + }); + + it('expands empty for a healthy (not-quarantined) tutorial', async () => { + const { data, status } = await project.get( + `/admin/Tutorials(ID=tut-ok,IsActiveEntity=true)?$expand=quarantineCurrent`, + auth, + ); + expect(status).toBe(200); + expect(data.quarantineCurrent).toEqual([]); + }); +}); diff --git a/srv/admin-service.cds b/srv/admin-service.cds index 52e6cd58f..d0e7b8ef0 100644 --- a/srv/admin-service.cds +++ b/srv/admin-service.cds @@ -128,6 +128,15 @@ service AdminService { // KgCommunityMembers is an @readonly projection on ims.KgCommunity, which // already carries the slug column. communityMembership : Association to many AdminService.KgCommunityMembers on communityMembership.slug = $self.slug, + // #2586 — active quarantine facet. Links this tutorial to the live + // quarantine set (events whose parent snapshot isCurrent=true), joined by + // the lowercase-canonical slug. Non-empty iff the most recent rebuild + // DROPPED this slug at pre-publish validation (scripts/validate-tutorials.ts) + // instead of publishing it — the "silent success" case in #2586. Each row + // carries reason + sourceUrl + the snapshot's workflowUrl/buildAt (aliased + // on QuarantineEventsCurrent). The OP renders a "Quarantined / not published" + // facet from a $expand on this association; empty = no active quarantine. + quarantineCurrent : Association to many AdminService.QuarantineEventsCurrent on quarantineCurrent.slug = $self.slug, }; // Filtered picklist for redirectTo value help — only ACTIVE tutorials can be redirect targets @readonly diff --git a/test/e2e/admin-tutorials-quarantine-facet.test.js b/test/e2e/admin-tutorials-quarantine-facet.test.js new file mode 100644 index 000000000..82bbecede --- /dev/null +++ b/test/e2e/admin-tutorials-quarantine-facet.test.js @@ -0,0 +1,101 @@ +// e2e: admin Tutorials Object Page "Active Quarantine" facet (#2586). +// Path: browser → approuter /admin-ui/#tutorials (XSUAA) → sap.tnt.ToolPage shell → +// sap.tutorials.admin.tutorials FE List Report → row-click → ObjectPage → +// CAP /admin/Tutorials?...$expand=quarantineCurrent OData. +// +// #2586 adds an "Active Quarantine" facet to the Tutorials OP, bound to the +// Tutorials.quarantineCurrent association (slug → QuarantineEventsCurrent, the +// #2585 live set). A force-rebuild that quarantines a tutorial at pre-publish +// validation now surfaces here instead of looking like a silent success. +// +// A unit test proves the association + $expand server-side (see +// srv/__tests__/admin-service-quarantine-facet.test.js). This e2e locks the +// SHIPPED seam that unit tests can't (the #1371/#1378 "features ship dead, gates +// green" pattern): that the FE ObjectPage actually requests the expand and +// renders the section. Both assertions are DATA-INDEPENDENT — they do not +// require any tutorial on the target env to be currently quarantined: +// 1. Opening a Tutorial OP issues an OData read carrying +// $expand=quarantineCurrent (proves the facet annotation + association +// shipped and FE binds them). +// 2. The "Active Quarantine" section anchor renders in the OP (proves the +// ReferenceFacet is wired into @UI.Facets). +// +// Self-skips without PLAYWRIGHT_BASE_URL/SMOKE_BASE_URL + credentials (repo e2e +// convention, #1338). Run post-deploy only: +// npx vitest run --project e2e test/e2e/admin-tutorials-quarantine-facet.test.js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { hasBaseUrl, hasCredentials } from './e2e.config.js'; +import { launchBrowser, newPage, requireCredentials } from './_browser.js'; + +const LIST_LOCATOR = '[role="list"], [role="grid"], .sapMList, .sapUiTable'; +const ROW_LOCATOR = '.sapMListItems .sapMLIB, .sapUiTableRow'; + +describe.skipIf(!hasBaseUrl() || !hasCredentials())('e2e: admin Tutorials quarantine facet', () => { + let browser; + beforeAll(async () => { + requireCredentials(); + browser = await launchBrowser(); + }); + afterAll(async () => { + await browser?.close(); + }); + + it('opening a Tutorial OP issues a /admin/Tutorials read with $expand=quarantineCurrent', async () => { + const { context, page } = await newPage(browser); + try { + await page.goto('/admin-ui/#tutorials', { waitUntil: 'domcontentloaded' }); + await page.locator(LIST_LOCATOR).first().waitFor({ state: 'visible', timeout: 30_000 }); + + // Arm the request matcher before the row-click so we catch the OP read. + // FE expands facet associations when the ObjectPage binds; quarantineCurrent + // is one of them. The term is data-independent — any Tutorial OP read works. + const expandRequest = page.waitForRequest( + (req) => + /\/admin\/Tutorials/.test(req.url()) && + /[?&]\$expand=[^&]*quarantineCurrent/.test(decodeURIComponent(req.url())), + { timeout: 30_000 } + ); + + const firstRow = page.locator(ROW_LOCATOR).first(); + await firstRow.waitFor({ state: 'visible', timeout: 30_000 }); + await firstRow.click(); + + const req = await expandRequest; + expect( + decodeURIComponent(req.url()), + 'Tutorial OP read should $expand quarantineCurrent' + ).toMatch(/[?&]\$expand=[^&]*quarantineCurrent/); + } finally { + await context.close(); + } + }); + + it('the Tutorial OP renders the "Active Quarantine" section', async () => { + const { context, page } = await newPage(browser); + try { + await page.goto('/admin-ui/#tutorials', { waitUntil: 'domcontentloaded' }); + await page.locator(LIST_LOCATOR).first().waitFor({ state: 'visible', timeout: 30_000 }); + + const firstRow = page.locator(ROW_LOCATOR).first(); + await firstRow.waitFor({ state: 'visible', timeout: 30_000 }); + await firstRow.click(); + + // Wait for the ObjectPage to render, then assert the facet section anchor. + // FE renders a ReferenceFacet's Label as section text; "Active Quarantine" + // appears whether or not the facet has rows (empty section still renders). + await page.waitForFunction( + () => document.querySelector('.sapUxAPObjectPageLayout') !== null, + { timeout: 30_000 } + ); + await page.getByText('Active Quarantine', { exact: false }) + .first() + .waitFor({ state: 'visible', timeout: 30_000 }); + expect( + await page.getByText('Active Quarantine', { exact: false }).count(), + '"Active Quarantine" facet section should render on the Tutorial OP' + ).toBeGreaterThan(0); + } finally { + await context.close(); + } + }); +}); From 5098f1f3a73506c0f3d0c2f9b4b402e1cec9897d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:37:33 -0400 Subject: [PATCH 27/37] feat(a2a): approuter /a2a as token-as-credential (none) for IAS (#2593) --- approuter/xs-app.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/approuter/xs-app.json b/approuter/xs-app.json index 0a429436c..988392ce9 100644 --- a/approuter/xs-app.json +++ b/approuter/xs-app.json @@ -585,9 +585,8 @@ "source": "^/a2a/?$", "target": "/a2a", "destination": "srv-api", - "authenticationType": "xsuaa", - "csrfProtection": false, - "scope": "$XSAPPNAME.Tutorial.MCP" + "authenticationType": "none", + "csrfProtection": false }, { "source": "^/tutorial-navigator-qa/?(\\?.*)?$", From 18ee749a4f9cfb5c9b82e496c43cfe582d3f7240 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:45:25 -0400 Subject: [PATCH 28/37] docs(a2a): rewrite served guides for IAS client-credentials (#2593) --- docs/end-users/a2a-quickstart.md | 72 +++++++++++++++----------------- hugo/content/api-docs/_index.md | 2 +- srv/mcp/a2a-instructions.md | 6 +-- 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/docs/end-users/a2a-quickstart.md b/docs/end-users/a2a-quickstart.md index 355744972..4fc266d5a 100644 --- a/docs/end-users/a2a-quickstart.md +++ b/docs/end-users/a2a-quickstart.md @@ -13,10 +13,10 @@ agent: you hand it a natural-language task and it runs its own agentic loop (sea graph, progress) and returns a completed A2A `Task`. > **Two audiences, one endpoint.** The Agent Card is **public** — anyone can discover the agent -> and its skills without a token. Actually *calling* a skill requires an XSUAA bearer with the -> `Tutorial.MCP` scope via **OAuth2 client-credentials** — a machine-to-machine flow that is -> provisioned by a BTP admin, not self-service. If you only want to inspect the agent, start at -> [Public discovery](#public-discovery) and stop there. +> and its skills without a token. Actually *calling* a skill requires a SAP IAS bearer token via +> **OAuth2 client-credentials** (machine-to-machine) — provisioned by a BTP admin, not self-service. +> There is no PKCE, interactive, or self-service PAT path for A2A. If you only want to inspect the +> agent, start at [Public discovery](#public-discovery) and stop there. ## Available skills @@ -25,11 +25,11 @@ to use `tutorial-chat`. | Skill (`skillId`) | Auth | What it does | |---|---|---| -| `tutorial-chat` | scope | Conversational Q&A over tutorials, missions, and learning paths. Runs the full agentic loop. **Default**; supports streaming via `message/stream`. | -| `search-tutorials` | scope | Semantic/keyword search over the tutorial catalog. | -| `user-progress` | scope **+ forwarded user token** | The signed-in developer's tutorial/mission progress. Returns empty results if the end-user's identity is not forwarded. | -| `knowledge-graph` | scope | Concept expansion and learning-path reasoning over the tutorial knowledge graph. | -| `tutorial-steps` | scope | Returns the most relevant tutorial step content so a calling agent can quote exact instructions. | +| `tutorial-chat` | IAS token | Conversational Q&A over tutorials, missions, and learning paths. Runs the full agentic loop. **Default**; supports streaming via `message/stream`. | +| `search-tutorials` | IAS token | Semantic/keyword search over the tutorial catalog. | +| `user-progress` | IAS token **+ forwarded user token** | The signed-in developer's tutorial/mission progress. Returns empty results if the end-user's identity is not forwarded. | +| `knowledge-graph` | IAS token | Concept expansion and learning-path reasoning over the tutorial knowledge graph. | +| `tutorial-steps` | IAS token | Returns the most relevant tutorial step content so a calling agent can quote exact instructions. | ## Base URLs @@ -50,7 +50,7 @@ Key fields: - `url` — the JSON-RPC endpoint (`/a2a`). - `skills[]` — the five skills above, with `id`, `description`, `tags`, and `examples`. -- `securitySchemes.xsuaa.flows.clientCredentials.tokenUrl` — the XSUAA token endpoint you +- `securitySchemes.ias.flows.clientCredentials.tokenUrl` — the SAP IAS token endpoint you authenticate against (see below). - `capabilities.streaming` — `true` (SSE via `message/stream`). - `documentationUrl` — points at `/.well-known/a2a-instructions.md`, the canonical @@ -60,46 +60,42 @@ Key fields: ## Authentication (internal / partner) -All `/a2a` calls require an XSUAA bearer carrying the **`Tutorial.MCP`** scope, obtained via -**OAuth2 client-credentials**. There is no PAT or interactive PKCE path — this is a -machine-to-machine flow. +All `/a2a` calls require a SAP IAS access token obtained via **OAuth2 client-credentials** +(`grant_type=client_credentials`) against the IAS token endpoint +`https://atxgsg7zi.accounts.ondemand.com/oauth2/token`. There is **no** PKCE, interactive +browser, or self-service PAT path for A2A — this is a pure machine-to-machine flow. ### 1. Get client credentials -You need a `client_id` / `client_secret` pair whose XSUAA instance is granted the `Tutorial.MCP` -scope. There are two realistic paths: +You need a `client_id` / `client_secret` pair from the A2A IAS client bound to the platform. +The credentials live in the `tutorials-identity` IAS service binding (the `identity` service +instance adopted as `existing-service`): -- **Platform team (same app):** use the tutorial platform's own XSUAA service key, which already - owns the scope. Read it from the bound instance: - - ```bash - # dev - cf env tutorials-srv | sed -n '/xsuaa/,/}/p' # → VCAP_SERVICES.xsuaa[0].credentials - # prod - cf env tutorials-prod-srv | sed -n '/xsuaa/,/}/p' - ``` +```bash +# dev +cf env tutorials-srv | jq '.VCAP_SERVICES.identity[0].credentials' +# prod +cf env tutorials-prod-srv | jq '.VCAP_SERVICES.identity[0].credentials' +``` - The `credentials` object carries `clientid`, `clientsecret`, and `url` (the XSUAA tenant base; - append `/oauth/token`). The `clientid` is environment-specific — `sb-tutorials!…` on dev, - `sb-tutorials-prod!…` on prod (dev and prod share one XSUAA tenant, so prod uses the distinct - `tutorials-prod` xsappname). +The `credentials` object carries `clientid` and `clientsecret`. Use these as `` and +`` in the token exchange below. The token endpoint is always +`https://atxgsg7zi.accounts.ondemand.com/oauth2/token` (the IAS tenant is `atxgsg7zi`). -- **A separate consumer (e.g. a standalone central Joule):** the consumer's own XSUAA instance - must be granted the `Tutorial.MCP` scope as a foreign-authority grant. **This is a BTP-admin - step** (an `xs-security.json` `granted-apps` / authority grant on the tutorial platform's side, - matched by the consumer's `xs-security.json`). It is not self-service — open an issue or contact - the platform team to arrange the grant for your subaccount. The exact grant recipe is - intentionally not reproduced here; it depends on the consuming app's xsappname. +If you are integrating a separate consumer (e.g. a standalone central Joule instance), the +consumer's IAS application must be registered in the same IAS tenant and granted the `openid` +scope against the platform's IAS client. **This is a BTP-admin step** — contact the platform +team to arrange the application registration. It is not self-service. ### 2. Exchange for a token ```bash export TOKEN_URL="$(curl -s /.well-known/agent-card.json \ - | jq -r '.securitySchemes.xsuaa.flows.clientCredentials.tokenUrl')" + | jq -r '.securitySchemes.ias.flows.clientCredentials.tokenUrl')" export TOKEN="$(curl -s "$TOKEN_URL" \ - -u "$CLIENT_ID:$CLIENT_SECRET" \ - -d 'grant_type=client_credentials' | jq -r '.access_token')" + -u ":" \ + -d 'grant_type=client_credentials&scope=openid' | jq -r '.access_token')" ``` For `user-progress`, additionally forward the end-user's identity token (the platform reads the @@ -174,7 +170,7 @@ not environment variables, and changes take effect within ~5 seconds without a r - **A2A Enabled** — master switch; off → `POST /a2a` returns 503 and the card signals unavailability. - **Public Base URL** — the base advertised in the Agent Card `url`; blank auto-detects from `VCAP_APPLICATION.application_uris`. -- **OAuth Token URL** — the XSUAA token endpoint advertised in the card's security scheme. +- **OAuth Token URL** — the SAP IAS token endpoint advertised in the card's `ias` security scheme. ## See also diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md index f42df1299..c641732e1 100644 --- a/hugo/content/api-docs/_index.md +++ b/hugo/content/api-docs/_index.md @@ -177,7 +177,7 @@ curl -X POST /a2a \ ### Auth -Every `/a2a` call needs an XSUAA bearer carrying the `Tutorial.MCP` scope, obtained via **OAuth2 client-credentials** against the `tokenUrl` in the card's `securitySchemes.xsuaa`. This is a machine-to-machine flow — there is **no** self-service PAT/PKCE path like the MCP signed-in tools. The Agent Card itself is public; `user-progress` additionally needs the end-user's identity forwarded. +Every `/a2a` call needs a SAP IAS access token (tenant `atxgsg7zi`), obtained via OAuth2 client-credentials against the `tokenUrl` in the agent card's `ias` security scheme. A2A is machine-to-machine: there is no interactive PKCE or self-service PAT path. Full connection walkthrough (public probe, client-credentials, streaming, errors, Joule wiring): [A2A Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/a2a-quickstart.md). Canonical served guide: [`/.well-known/a2a-instructions.md`](https://github.com/sap-tutorials/tutorials-ims/blob/main/srv/mcp/a2a-instructions.md). diff --git a/srv/mcp/a2a-instructions.md b/srv/mcp/a2a-instructions.md index a62f656ed..c74eb0b28 100644 --- a/srv/mcp/a2a-instructions.md +++ b/srv/mcp/a2a-instructions.md @@ -14,8 +14,8 @@ It declares five skills: `tutorial-chat` (conversational, streaming), `search-tu ## Authentication -All `/a2a` calls require an XSUAA JWT carrying the `Tutorial.MCP` scope (OAuth2 -client-credentials against the `tokenUrl` in the card's `securitySchemes.xsuaa`). +All `/a2a` calls require a SAP IAS access token obtained via OAuth2 +client-credentials against the `tokenUrl` in the card's `securitySchemes.ias`. `user-progress` additionally needs the end-user's identity forwarded in the token; without it, it returns empty results. @@ -64,7 +64,7 @@ panel on the Joule settings page), stored on the `ChatSettings` singleton: unavailability. - **Public Base URL** — the base advertised in the Agent Card `url`; leave blank to auto-detect from the platform (`VCAP_APPLICATION.application_uris`). -- **OAuth Token URL** — the XSUAA token endpoint advertised in the card's security scheme. +- **OAuth Token URL** — the SAP IAS token endpoint advertised in the card's `ias` security scheme. Changes take effect within ~5 seconds (no restart). These are DB-backed settings, not environment variables. From 6c9bab9548f106cf45e19fa3a5c30f9fa1955953 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:51:48 -0400 Subject: [PATCH 29/37] docs(a2a): add /a2a row to API inventory, fix drift (#2593) --- docs/developers/operations/testing-endpoints.md | 1 + hugo/data/api_endpoints.yaml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index 9f8f1ea25..a16fa8264 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -181,6 +181,7 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug | `/feedback/submit` | POST | Tutorial feedback form (rate-limited; submitter IP hashed via `SUBMISSION_SALT_SECRET`) | None | | `/chat/stream` | POST | Joule chat streaming endpoint (Server-Sent Events) | XSUAA | | `/a2a` | POST | A2A JSON-RPC 2.0 endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) for central Joule consumption (#1220). Skill via `metadata.skillId`; defaults to conversational `tutorial-chat`. Enable/config via `/admin-ui/#joule` (ChatSettings `a2aEnabled`). | XSUAA + `Tutorial.MCP` | +| `/a2a` | POST | A2A agent JSON-RPC, machine-to-machine. IAS (client-credentials) — SAP IAS access token via OAuth2 client-credentials (tenant `atxgsg7zi`), no interactive/PKCE/PAT path. Agent card at `/.well-known/agent-card.json`. | IAS (client-credentials) | | `/.well-known/agent-card.json` | GET | A2A Agent Card — public discovery document (5 skills, streaming, xsuaa security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None | | `/.well-known/a2a-instructions.md` | GET | A2A consumption guide (how to authenticate + call) | None | | `/.well-known/tutorial-provenance/jwks.json` | GET | Ed25519 JWKS for verifying tutorial provenance JWS tokens (`{ keys: [...] }`). Returns 404 when `PROVENANCE_ENVELOPE_ENABLED` flag is OFF. Anonymous Express route — `@requires` not applicable. Pair: `/content/tutorials/:slug/provenance`. | None | diff --git a/hugo/data/api_endpoints.yaml b/hugo/data/api_endpoints.yaml index 19d8342cc..23441c419 100644 --- a/hugo/data/api_endpoints.yaml +++ b/hugo/data/api_endpoints.yaml @@ -132,6 +132,11 @@ sections: driftMarkers: - "/pats" - "mintPAT" + - path: "/a2a" + auth: "IAS (client-credentials)" + purpose: 'A2A agent (JSON-RPC). Machine-to-machine — SAP IAS access token via OAuth2 client-credentials (tenant atxgsg7zi), no interactive/PKCE/PAT path. Agent card at /.well-known/agent-card.json.' + driftMarkers: + - "/a2a" feeds: columns: [path, format, contents] From f74bb5a0d1a32e84f4903d0077ef57f62033301d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:54:53 -0400 Subject: [PATCH 30/37] docs(a2a): consolidate /a2a endpoint row to IAS, drop stale XSUAA (#2593) --- docs/developers/operations/testing-endpoints.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index a16fa8264..229c2f3a0 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -180,8 +180,7 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug | `/api/advocates/:slug/photo[?size=thumb]` | GET | Photo bytes (256-WebP default, 64-WebP with `?size=thumb`) served from HANA `AdvocatePhotos`. ETag is the sha256, `max-age=86400`. 404 when the advocate has no photo or slug is unknown. | None | | `/feedback/submit` | POST | Tutorial feedback form (rate-limited; submitter IP hashed via `SUBMISSION_SALT_SECRET`) | None | | `/chat/stream` | POST | Joule chat streaming endpoint (Server-Sent Events) | XSUAA | -| `/a2a` | POST | A2A JSON-RPC 2.0 endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) for central Joule consumption (#1220). Skill via `metadata.skillId`; defaults to conversational `tutorial-chat`. Enable/config via `/admin-ui/#joule` (ChatSettings `a2aEnabled`). | XSUAA + `Tutorial.MCP` | -| `/a2a` | POST | A2A agent JSON-RPC, machine-to-machine. IAS (client-credentials) — SAP IAS access token via OAuth2 client-credentials (tenant `atxgsg7zi`), no interactive/PKCE/PAT path. Agent card at `/.well-known/agent-card.json`. | IAS (client-credentials) | +| `/a2a` | POST | A2A JSON-RPC 2.0 endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) for central Joule consumption (#1220); machine-to-machine. Skill via `metadata.skillId`, defaults to conversational `tutorial-chat`. Enable/config via `/admin-ui/#joule` (ChatSettings `a2aEnabled`). Agent card at `/.well-known/agent-card.json`. | IAS (client-credentials) | | `/.well-known/agent-card.json` | GET | A2A Agent Card — public discovery document (5 skills, streaming, xsuaa security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None | | `/.well-known/a2a-instructions.md` | GET | A2A consumption guide (how to authenticate + call) | None | | `/.well-known/tutorial-provenance/jwks.json` | GET | Ed25519 JWKS for verifying tutorial provenance JWS tokens (`{ keys: [...] }`). Returns 404 when `PROVENANCE_ENVELOPE_ENABLED` flag is OFF. Anonymous Express route — `@requires` not applicable. Pair: `/content/tutorials/:slug/provenance`. | None | From 109b05c0740339c2f7680352e67647e3f485a4f8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:58:11 -0400 Subject: [PATCH 31/37] docs(a2a): fix stale xsuaa agent-card scheme note (#2593) --- docs/developers/operations/testing-endpoints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index 229c2f3a0..61592e2ef 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -181,7 +181,7 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug | `/feedback/submit` | POST | Tutorial feedback form (rate-limited; submitter IP hashed via `SUBMISSION_SALT_SECRET`) | None | | `/chat/stream` | POST | Joule chat streaming endpoint (Server-Sent Events) | XSUAA | | `/a2a` | POST | A2A JSON-RPC 2.0 endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) for central Joule consumption (#1220); machine-to-machine. Skill via `metadata.skillId`, defaults to conversational `tutorial-chat`. Enable/config via `/admin-ui/#joule` (ChatSettings `a2aEnabled`). Agent card at `/.well-known/agent-card.json`. | IAS (client-credentials) | -| `/.well-known/agent-card.json` | GET | A2A Agent Card — public discovery document (5 skills, streaming, xsuaa security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None | +| `/.well-known/agent-card.json` | GET | A2A Agent Card — public discovery document (5 skills, streaming, IAS security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None | | `/.well-known/a2a-instructions.md` | GET | A2A consumption guide (how to authenticate + call) | None | | `/.well-known/tutorial-provenance/jwks.json` | GET | Ed25519 JWKS for verifying tutorial provenance JWS tokens (`{ keys: [...] }`). Returns 404 when `PROVENANCE_ENVELOPE_ENABLED` flag is OFF. Anonymous Express route — `@requires` not applicable. Pair: `/content/tutorials/:slug/provenance`. | None | | `/api/codecheck` | POST | AI code-check spike (issue #171, gated on `ChatSettings.codeCheckEnabled`). Body: `{ tutorialSlug, stepNumber, submittedCode, language? }`. Returns `{ verdict: 'pass'\|'partial'\|'fail', summary, suggestions[], correctAspects[] }`. 503 when flag off; 429 with `Retry-After` on per-user 30/hr or per-(user,slug,step) 5/5min cap. | XSUAA | From 4ad036bbe36b1b6c566b4ea8fa1950e1294759ef Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 13:10:02 -0400 Subject: [PATCH 32/37] fix(a2a): set XSUAA_MCP_URL on srv for card tokenUrl + ias scheme desc (#2593) --- app/admin-annotations.cds | 2 +- deploy/dev.mtaext | 3 +++ deploy/prod.mtaext | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 5a283a3ce..a1147e0ff 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -2158,7 +2158,7 @@ annotate AdminService.ChatSettings with @( embeddingMinScore @Common.Label: 'Min Similarity Score'; a2aEnabled @Common.Label: 'A2A Enabled' @description: 'Master switch for the /a2a endpoint and Agent Card. When off, /a2a returns 503 and the card signals unavailability.'; a2aPublicBaseUrl @Common.Label: 'A2A Public Base URL' @description: 'Base URL advertised in the Agent Card url; blank = auto-detect from platform (VCAP application_uris).'; - a2aTokenUrl @Common.Label: 'A2A Token URL' @description: 'OAuth token endpoint advertised in the Agent Card xsuaa security scheme.'; + a2aTokenUrl @Common.Label: 'A2A Token URL' @description: 'OAuth token endpoint advertised in the Agent Card ias security scheme.'; }; annotate AdminService.TutorialFeedback with { diff --git a/deploy/dev.mtaext b/deploy/dev.mtaext index 7338dba47..cbfe0bbe0 100644 --- a/deploy/dev.mtaext +++ b/deploy/dev.mtaext @@ -37,6 +37,9 @@ modules: # redeploy silently dropped — same override-survival reason as the KG # flag above. srv/lib/github-app-token.js reads this at resolveGithubToken. USE_GITHUB_APP: 'true' + # A2A agent card tokenUrl derivation (srv/lib/runtime-config/a2a-settings.js reads XSUAA_MCP_URL) — #2593 + XSUAA_MCP_URL: https://atxgsg7zi.accounts.ondemand.com + MCP_ISSUER_KIND: ias # NOTE: KG_PATH_V2_ENABLED moved to DB-driven config (ImsConfig # flag.kg.pathV2, issue #2060). Enable it for DEV from the Admin UI / # setFeatureFlag action instead of pinning an env override here. diff --git a/deploy/prod.mtaext b/deploy/prod.mtaext index b5999b025..76b04e815 100644 --- a/deploy/prod.mtaext +++ b/deploy/prod.mtaext @@ -30,6 +30,9 @@ modules: # resolveGithubToken (App-first, PAT fallback). Requires the GitHub App # installed on tutorials-ims with Actions:write + Contents:write. USE_GITHUB_APP: 'true' + # A2A agent card tokenUrl derivation (srv/lib/runtime-config/a2a-settings.js reads XSUAA_MCP_URL) — #2593 + XSUAA_MCP_URL: https://atxgsg7zi.accounts.ondemand.com + MCP_ISSUER_KIND: ias parameters: # 2 instances: redundancy floor for prod (survives instance loss + rolling # deploys) without the downsides of higher counts. Was briefly 4 for the From 4d16039f39b38247b7fc28c357131a3126bad18f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 13:59:12 -0400 Subject: [PATCH 33/37] fix(db): add missing migration=5 for Users.pictureUrl PR #2628 added Users.pictureUrl to db/schema.cds and db/last-dev/csn.json but never bumped the .hdbmigrationtable past version=4, so HDI never issued ALTER TABLE ... ADD (pictureUrl). The generated AdminService/AnalyticsService Users views select a column absent from the physical table, so HDI deploy fails with 'invalid column name: PICTUREURL'. Same failure class and remediation as 442004128 (ContentFiles.sourceRepo). Verified: cds build --production regenerates an identical gen/ migration table (no src-vs-gen diff). --- .../com.sap.developers.ims.Users.hdbmigrationtable | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/db/src/com.sap.developers.ims.Users.hdbmigrationtable b/db/src/com.sap.developers.ims.Users.hdbmigrationtable index fe8ba2506..94bf35143 100644 --- a/db/src/com.sap.developers.ims.Users.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.Users.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=4 +== version=5 COLUMN TABLE com_sap_developers_ims_Users ( ID NVARCHAR(36) NOT NULL, createdAt TIMESTAMP, @@ -16,10 +16,21 @@ COLUMN TABLE com_sap_developers_ims_Users ( khorosLogin NVARCHAR(64), khorosAvatarUrl NVARCHAR(1000), khorosLinkedAt TIMESTAMP, + pictureUrl NVARCHAR(1024), githubLogin NVARCHAR(64), PRIMARY KEY(ID) ) +== migration=5 +-- Issue #2628 — add login-provider profile picture URL (OIDC `picture` claim). +-- Column added to entity Users (db/schema.cds) + db/last-dev/csn.json in #2628, +-- but the .hdbmigrationtable was never bumped past version=4, so HDI never issued +-- ALTER TABLE ... ADD (pictureUrl). The generated AdminService/AnalyticsService +-- Users views then select a column absent from the physical table → HDI deploy +-- fails with 'invalid column name: PICTUREURL'. Same class as the sourceRepo +-- fix in 442004128; hand-authored to match the version=4 (githubLogin) shape. +ALTER TABLE com_sap_developers_ims_Users ADD (pictureUrl NVARCHAR(1024)); + == migration=4 -- generated by cds-compiler version 6.9.0 ALTER TABLE com_sap_developers_ims_Users ADD (githubLogin NVARCHAR(64)); From ed76c0a953a12ce3549b5f368cb8245071f25e5a Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 14:42:31 -0400 Subject: [PATCH 34/37] fix(content): export quarantineIngestHandler from barrel index.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2585 added quarantineIngestHandler to packages/content/content-store.js and wired POST /content/quarantine-events in srv/server.js, but never added it to the barrel re-export in packages/content/index.js. Local dev imports via the deep subpath (@tutorials/content/content-store.js) which has the handler, masking the gap. At CF deploy, srv/lib/content-store.js falls back to the bundle srv/lib/_shared/content.bundle.mjs, which esbuild builds from the index.js barrel entry — so the handler was absent, resolving to undefined. app.post(..., undefined) then crashes tutorials-srv on boot: 'TypeError: argument handler must be a function' at srv/server.js:977. Verified: after this one-line export, a regenerated bundle resolves quarantineIngestHandler as a function (was undefined). --- packages/content/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/content/index.js b/packages/content/index.js index 7987831da..b775a6fdc 100644 --- a/packages/content/index.js +++ b/packages/content/index.js @@ -8,7 +8,7 @@ // Test helpers (_resetForTest etc.) are not included in the barrel — use subpath imports. // content-store.js -export { createContentHandlers, toBuffer, isCatalogSlug, dropCatalogSlugs, recomputeTutorialProgress, triggerPostPublishEmbeddings, DEFAULT_CONTENT_CACHE_TTL_MS, ContentCache, invalidateRenderCache, invalidateContentCache, contentAuthMiddleware, publishHandler, serveHandler, markdownServeHandler, hashesHandler, sourceHashesHandler, getTutorialSource, navHandler, rollbackHandler, orphanPurgeHandler, beginHandler, appendHandler, commitHandler, abortHandler, pipelineLogFailureHandler, pageServeHandler, authorServeHandler, advocateServeHandler } from './content-store.js'; +export { createContentHandlers, toBuffer, isCatalogSlug, dropCatalogSlugs, recomputeTutorialProgress, triggerPostPublishEmbeddings, DEFAULT_CONTENT_CACHE_TTL_MS, ContentCache, invalidateRenderCache, invalidateContentCache, contentAuthMiddleware, publishHandler, serveHandler, markdownServeHandler, hashesHandler, sourceHashesHandler, getTutorialSource, navHandler, rollbackHandler, orphanPurgeHandler, beginHandler, appendHandler, commitHandler, abortHandler, pipelineLogFailureHandler, quarantineIngestHandler, pageServeHandler, authorServeHandler, advocateServeHandler } from './content-store.js'; // content-publish-session.js export { createSessionHelpers, classifyTouchedTutorials } from './content-publish-session.js'; // catalog-data.js From 04f8b890294da547dc3c0b862b8a95245973c631 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 16:39:58 -0400 Subject: [PATCH 35/37] fix(auth): scope IAS validation to /a2a; restore XSUAA browser login (#2593 regression) PR #2593 added a SERVICE-WIDE cds.requires.auth = {kind:'ias', xsuaa:true} to let the a2a (agent-to-agent) endpoint accept IAS client-credentials tokens. On the deployed DEV srv the XSUAA fallback did not engage, so every browser XSUAA token (aud tutorials!t676072) failed audience validation against the IAS client (GUID 0b1e8b56) -> all authenticated routes 500/401, DEV login fully broken. #2593's own design doc flagged this global auth kind as the "single highest-risk change". Fix (route-scoped IAS): - Remove the service-wide auth block from package.json -> main srv reverts to its pre-#2593 known-good XSUAA-via-approuter default on every human route (/auth/user, /api/ChatConfig, admin UI, content, /mcp-admin). - Add srv/lib/a2a/ias-auth-mw.js: a route-scoped IAS (+XSUAA fallback) auth middleware reusing CAP's own ias-auth factory (same mapping global auth uses, so cds.context.user.id / .is(role) are unchanged). Degrades closed: with no IAS binding (unit/local) or if CAP's internal factory path moves, it passes through leaving the user anonymous -> rpc-router.js returns -32001/401. Never throws into boot; never affects any route but /a2a. - Wire it into the POST /a2a chain in server.js, replacing the generic authMw (contextMw still runs first; rpc-router anonymous guard unchanged). MTA bindings and the approuter /a2a route (authenticationType:"none") are intentionally untouched -- tutorials-identity stays bound for the a2a validator, and #2593's a2a-IAS capability keeps working. Tests: new unit test asserts degrade-closed contract (3/3 pass); full a2a suite 64/64; auth-guard suites that run assertions all green. --- package.json | 4 - srv/lib/a2a/ias-auth-mw.js | 135 ++++++++++++++++++++++++++++++ srv/server.js | 12 ++- test/unit/a2a/ias-auth-mw.test.js | 38 +++++++++ 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 srv/lib/a2a/ias-auth-mw.js create mode 100644 test/unit/a2a/ias-auth-mw.test.js diff --git a/package.json b/package.json index a68daf732..c1dff5896 100644 --- a/package.json +++ b/package.json @@ -242,10 +242,6 @@ } }, "requires": { - "auth": { - "[hybrid]": { "kind": "ias", "xsuaa": true }, - "[production]": { "kind": "ias", "xsuaa": true } - }, "AICore": { "resourceGroup": "default", "[hybrid]": { diff --git a/srv/lib/a2a/ias-auth-mw.js b/srv/lib/a2a/ias-auth-mw.js new file mode 100644 index 000000000..981faaa08 --- /dev/null +++ b/srv/lib/a2a/ias-auth-mw.js @@ -0,0 +1,135 @@ +// Route-scoped IAS (+XSUAA fallback) auth middleware for POST /a2a. +// +// Background (#2593 → DEV login outage, 2026-10-04): #2593 set a SERVICE-WIDE +// cds.requires.auth = {kind:'ias', xsuaa:true} to let the a2a (agent-to-agent) +// surface accept IAS client-credentials tokens. On the deployed DEV srv the +// XSUAA fallback did NOT engage, so every browser XSUAA token failed audience +// validation against the IAS client → all authenticated routes 500/401. The +// service-wide auth kind was the "single highest-risk change" the #2593 spec +// itself flagged (docs/.../2026-10-04-a2a-ias-auth-migration-design.md). +// +// Fix (route-scoped IAS, user-approved): drop the service-wide auth kind so +// human routes revert to their pre-#2593 known-good XSUAA-via-approuter path, +// and validate IAS/XSUAA tokens ONLY here, on the single machine-to-machine +// endpoint that needs it. This reuses CAP's own ias-auth factory (same code +// path global auth uses), so the downstream contract in rpc-router.js (reads +// cds.context.user.id, rejects anonymous with -32001/401) is unchanged. +// +// Degrade-closed: if the IAS binding is absent (unit tests, local dev) or CAP's +// internal factory module moves on upgrade, this returns a pass-through that +// leaves the user anonymous — rpc-router.js then rejects with 401. It never +// throws into boot and never affects any route other than /a2a. + +import cds from '@sap/cds'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const LOG = cds.log('a2a'); + +// CAP's ias auth middleware factory is an INTERNAL (non-released-API) path. +// Per the project rule against depending on unpublished internals, we require +// it defensively: a failure here degrades /a2a to anonymous, it does not crash. +const IAS_AUTH_FACTORY_PATH = '@sap/cds/lib/srv/middlewares/auth/ias-auth'; + +// Instance names of the bound services (see .deploy/mta.yaml / mta.yaml). +const IAS_BINDING_NAME = 'tutorials-identity'; +const XSUAA_BINDING_NAME = 'tutorials-xsuaa'; + +// Pass-through used whenever IAS validation can't be wired. Leaves the user +// untouched (anonymous) so the existing rpc-router anonymous guard returns 401. +const PASS_THROUGH = (_req, _res, next) => next(); + +let _cached; // memoized (req,res,next) middleware; built on first use. + +/** + * Lazily build the route-scoped IAS (+XSUAA fallback) auth middleware. + * Built on first request (not at import) because service bindings are not + * present in VCAP_SERVICES during unit tests / local boot. + * @returns {(req, res, next) => void} + */ +function buildIasAuthMiddleware() { + let xsenv; + let makeIasAuth; + try { + xsenv = require('@sap/xsenv'); + makeIasAuth = require(IAS_AUTH_FACTORY_PATH); + } catch (err) { + LOG.warn( + `/a2a IAS auth unavailable (require failed: ${err.message}); ` + + 'a2a will reject as anonymous. Human routes are unaffected.', + ); + return PASS_THROUGH; + } + + // CAP's factory is a CJS default export; interop may wrap it as { default }. + const factory = typeof makeIasAuth === 'function' ? makeIasAuth : makeIasAuth?.default; + if (typeof factory !== 'function') { + LOG.warn('/a2a IAS auth factory export is not callable; a2a will reject as anonymous.'); + return PASS_THROUGH; + } + + let ias; + let xsuaa; + try { + ({ ias, xsuaa } = xsenv.getServices({ + ias: { name: IAS_BINDING_NAME }, + xsuaa: { name: XSUAA_BINDING_NAME }, + })); + } catch (err) { + // Missing bindings (unit/local) — degrade closed, no a2a auth. + LOG.warn( + `/a2a IAS binding '${IAS_BINDING_NAME}' not found (${err.message}); ` + + 'a2a will reject as anonymous.', + ); + return PASS_THROUGH; + } + + if (!ias || !ias.clientid) { + LOG.warn(`/a2a IAS binding '${IAS_BINDING_NAME}' has no credentials; a2a will reject as anonymous.`); + return PASS_THROUGH; + } + + // Expose the XSUAA binding under cds.env.requires.xsuaa.credentials so CAP's + // ias-auth factory builds its automatic XSUAA-fallback service (non-breaking + // acceptance of XSUAA bearers during the a2a token-source cutover). + const hasXsuaa = Boolean(xsuaa && xsuaa.clientid); + if (hasXsuaa) { + cds.env.requires = cds.env.requires || {}; + cds.env.requires.xsuaa = { ...(cds.env.requires.xsuaa || {}), kind: 'xsuaa', credentials: xsuaa }; + } + + try { + const mw = factory({ + kind: 'ias', + credentials: ias, + // Point the factory at the requires entry we just populated so it wires + // the XSUAA fallback. Omitted when no XSUAA binding → IAS-only. + ...(hasXsuaa ? { xsuaa: 'xsuaa' } : {}), + }); + LOG.info( + `/a2a IAS auth wired (IAS '${IAS_BINDING_NAME}'` + + `${hasXsuaa ? ` + XSUAA fallback '${XSUAA_BINDING_NAME}'` : ''}).`, + ); + return typeof mw === 'function' ? mw : PASS_THROUGH; + } catch (err) { + LOG.warn(`/a2a IAS auth factory failed (${err.message}); a2a will reject as anonymous.`); + return PASS_THROUGH; + } +} + +/** + * Route-scoped auth middleware for POST /a2a. Validates an IAS bearer (with + * XSUAA fallback) and populates cds.context.user via CAP's own mapping; on any + * failure the user is left anonymous and rpc-router.js returns 401. MUST run + * after contextMw (which establishes cds.context). + */ +export function a2aIasAuthMiddleware(req, res, next) { + if (_cached === undefined) _cached = buildIasAuthMiddleware(); + return _cached(req, res, next); +} + +// Test seam: reset the memoized middleware so unit tests can rebuild against +// a mocked binding environment. +export function __resetForTests() { + _cached = undefined; +} diff --git a/srv/server.js b/srv/server.js index b563c48cd..55823fa5c 100644 --- a/srv/server.js +++ b/srv/server.js @@ -104,6 +104,7 @@ import { fetchPetPhoto } from './lib/petoberfest-photo-store.js'; import { installDbWrap } from './lib/metrics-db-wrap.js'; import './graphql-config.js'; import { makeA2aRouter } from './lib/a2a/rpc-router.js'; +import { a2aIasAuthMiddleware } from './lib/a2a/ias-auth-mw.js'; import { buildAgentCard } from './lib/a2a/agent-card.js'; import { resolveA2aSettings } from './lib/runtime-config/a2a-settings.js'; import { provenanceHandler, jwksHandler } from './lib/provenance-handlers.js'; @@ -2244,14 +2245,21 @@ cds.on('served', () => { cds.log('chat').info('POST /chat/stream registered'); - // Wire A2A router through the same context+auth chain. makeA2aRouter() + // Wire A2A router through context + ROUTE-SCOPED IAS auth. makeA2aRouter() // returns a router with router.post('/') which matches POST /a2a because // Express strips the base path when dispatching to the router. (#1220) + // + // Auth note (#2593 fix): /a2a uses a2aIasAuthMiddleware (IAS + XSUAA fallback, + // validated in-process) instead of the generic CAP authMw. The service-wide + // cds.requires.auth kind that #2593 added was removed because it broke every + // XSUAA browser route; IAS validation is now scoped to this one M2M endpoint. + // contextMw still runs first to establish cds.context; rpc-router.js rejects + // anonymous (-32001/401) if the middleware leaves no authenticated user. const a2aRouter = makeA2aRouter(); a2aHandler = (req, res, next) => { contextMw(req, res, (err) => { if (err) return next(err); - authMw(req, res, (err) => { + a2aIasAuthMiddleware(req, res, (err) => { if (err) return next(err); a2aRouter(req, res, next); }); diff --git a/test/unit/a2a/ias-auth-mw.test.js b/test/unit/a2a/ias-auth-mw.test.js new file mode 100644 index 000000000..6b6ff2ccc --- /dev/null +++ b/test/unit/a2a/ias-auth-mw.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { a2aIasAuthMiddleware, __resetForTests } from '../../../srv/lib/a2a/ias-auth-mw.js'; + +// Route-scoped IAS auth middleware for POST /a2a (#2593 fix). These tests assert +// the degrade-closed contract — with no IAS binding (unit/local), the middleware +// must call next() WITHOUT populating a user, so rpc-router.js rejects anonymous +// (-32001/401). The wired-binding path (CAP factory) is exercised in hybrid/smoke. + +describe('a2aIasAuthMiddleware (route-scoped IAS auth for /a2a)', () => { + beforeEach(() => __resetForTests()); + afterEach(() => __resetForTests()); + + it('degrades closed: with no IAS binding it calls next() and leaves no user', () => { + const req = { headers: {} }; + const res = {}; + const next = vi.fn(); + a2aIasAuthMiddleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + // pass-through: no error passed, no user attached to the request + expect(next.mock.calls[0][0]).toBeUndefined(); + expect(req.user).toBeUndefined(); + }); + + it('never throws into the request chain when bindings are absent', () => { + const next = vi.fn(); + expect(() => a2aIasAuthMiddleware({ headers: {} }, {}, next)).not.toThrow(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('memoizes the built middleware across requests (single build)', () => { + const n1 = vi.fn(); + const n2 = vi.fn(); + a2aIasAuthMiddleware({ headers: {} }, {}, n1); + a2aIasAuthMiddleware({ headers: {} }, {}, n2); + expect(n1).toHaveBeenCalledTimes(1); + expect(n2).toHaveBeenCalledTimes(1); + }); +}); From e8a2545e58f21830b837d743d52a8ac3f6ac8011 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 17:44:19 -0400 Subject: [PATCH 36/37] fix(deploy): correct MCP mtar build flag in docs; parallelize asset-retention smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deploy-infra fixes surfaced during the 2026-10-04 DEV redeploy: 1. mbt MCP build command was wrong in 5 places (CLAUDE.md + 4 docs): `mbt build -e mta-mcp.yaml` — but `-e` is `--extensions` (extension descriptors), so mbt parses mta-mcp.yaml as an mta.EXT and errors out ("field type not found in type mta.ResourceExt"). The correct flag to select a non-default MTA descriptor is `-f`/`--filename`. The wrong flag fails the build but can leave a STALE mtar in mta_archives/, so a follow-on `cf deploy mta_archives/*.mtar` ships an old artifact silently. Fixed all occurrences to `mbt build -f mta-mcp.yaml`. (Historical plan doc left as-is.) 2. test/smoke/asset-retention.smoke.test.js looped several hundred HEAD probes SEQUENTIALLY and blew past the 30s default testTimeout, flapping the whole smoke gate to "smoke_regressed" on healthy deploys (observed twice on 2026-10-04). Rewrote to a bounded (concurrency 20) worker pool collecting all non-200s into one assertion, with an explicit 60s timeout as a backstop. Same coverage (every retained bundle still probed), finishes in a few seconds. --- CLAUDE.md | 2 +- docs/developers/getting-started.md | 2 +- docs/developers/operations/deployment.md | 2 +- docs/developers/operations/mcp-server.md | 2 +- docs/developers/operations/mta-deployment.md | 2 +- test/smoke/asset-retention.smoke.test.js | 24 ++++++++++++++++---- 6 files changed, 24 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c12c46a06..78e3306e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Subsystem one-liners: - **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 + mbt build -f mta-mcp.yaml -t mta_archives cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f # or deploy/mcp-prod.mtaext ``` diff --git a/docs/developers/getting-started.md b/docs/developers/getting-started.md index aebf4806b..b9e5796cb 100644 --- a/docs/developers/getting-started.md +++ b/docs/developers/getting-started.md @@ -165,7 +165,7 @@ 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` +> `mbt build -f 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 diff --git a/docs/developers/operations/deployment.md b/docs/developers/operations/deployment.md index c4cbfcca8..7b586ba6a 100644 --- a/docs/developers/operations/deployment.md +++ b/docs/developers/operations/deployment.md @@ -20,7 +20,7 @@ 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 +mbt build -f mta-mcp.yaml -t mta_archives cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f ``` diff --git a/docs/developers/operations/mcp-server.md b/docs/developers/operations/mcp-server.md index a34453eb9..e5ef120b9 100644 --- a/docs/developers/operations/mcp-server.md +++ b/docs/developers/operations/mcp-server.md @@ -33,7 +33,7 @@ cd .deploy && mbt build && cf deploy mta_archives/*.mtar -e ../deploy/dev.mtaext > `tutorials-mcp` MTA (descriptor `mta-mcp.yaml`), not `tutorials-srv`. It deploys by hand: > > ```bash -> mbt build -e mta-mcp.yaml -t mta_archives +> mbt build -f mta-mcp.yaml -t mta_archives > cf deploy mta_archives/*.mtar -e deploy/mcp-dev.mtaext -f # or deploy/mcp-prod.mtaext > ``` > diff --git a/docs/developers/operations/mta-deployment.md b/docs/developers/operations/mta-deployment.md index ff54844c2..ce5208e0e 100644 --- a/docs/developers/operations/mta-deployment.md +++ b/docs/developers/operations/mta-deployment.md @@ -91,7 +91,7 @@ hand: ```bash # From repo root (the descriptor's before-all runs from root, no `cd ..`) -mbt build -e mta-mcp.yaml -t mta_archives +mbt build -f 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 ``` diff --git a/test/smoke/asset-retention.smoke.test.js b/test/smoke/asset-retention.smoke.test.js index b1e31a4a9..b50456bc3 100644 --- a/test/smoke/asset-retention.smoke.test.js +++ b/test/smoke/asset-retention.smoke.test.js @@ -47,10 +47,24 @@ describeIf('asset retention', () => { const manifest = await parseManifest(res); expect(Array.isArray(manifest)).toBe(true); - for (const { file } of manifest) { - const kind = file.endsWith('.css') ? 'css' : 'js'; - const r = await fetchWithRetry(`${BASE_URL}/${kind}/${file}`, { method: 'HEAD', redirect: 'follow' }); - expect(r.status, `${file} should serve 200`).toBe(200); + // Probe all bundles with bounded concurrency. The manifest holds the current + // build's bundles PLUS the prior build's retained ones (several hundred + // entries), so a sequential HEAD-per-bundle loop blows past the 30s default + // testTimeout and the whole smoke gate flaps "regressed" on a healthy deploy. + // A small worker pool keeps the live assertion but finishes in a few seconds. + const CONCURRENCY = 20; + const failures = []; + let cursor = 0; + async function worker() { + while (cursor < manifest.length) { + const { file } = manifest[cursor++]; + const kind = file.endsWith('.css') ? 'css' : 'js'; + const r = await fetchWithRetry(`${BASE_URL}/${kind}/${file}`, { method: 'HEAD', redirect: 'follow' }); + if (r.status !== 200) failures.push(`${file} → ${r.status}`); + } } - }); + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, manifest.length) }, worker)); + + expect(failures, `retained bundles must all serve 200; non-200: ${failures.join(', ')}`).toEqual([]); + }, 60000); }); From 263aebda011522b5db293ef7a4009084d0ca8dd5 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 18:08:14 -0400 Subject: [PATCH 37/37] chore(release): 1.35.0 Bump deploy version 1.34.0 -> 1.35.0 (.deploy/mta.yaml) for the DEV->main promotion and update the user-facing What's New digest (hugo/data/whats_new.json) with the 16 PRs merged since the 1.34.0 release cutoff (2026-10-04T00:16Z). Headline items in this train: - Restored developer-portal sign-in after a regression broke logins (route-scoped the agent-to-agent IAS auth so browser sign-in works again). - Migrated the agent-to-agent (A2A) endpoint to SAP IAS authentication. - Login-provider profile pictures (OIDC picture claim, Khoros avatar precedence). - Persistent tutorial-quarantine store + Admin UI, and the real rebuild outcome surfaced on the Tutorials object page. - Several reliability fixes: deploy blockers (barrel export, DB migration), underscore-slug 404s, anonymous search no longer surfacing deleted tutorials, hosted MCP PAT user resolution, and a semantic-search test flake. whats_new.json is the committed changelog source; srv/whats-new-data/ is rebuilt at build time and stays gitignored (not committed here). --- .deploy/mta.yaml | 2 +- hugo/data/whats_new.json | 210 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 8ec4daa15..95c1f44aa 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.34.0 +version: 1.35.0 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/hugo/data/whats_new.json b/hugo/data/whats_new.json index ef14466d7..3b4435ecf 100644 --- a/hugo/data/whats_new.json +++ b/hugo/data/whats_new.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-01T21:43:56.959Z", + "generatedAt": "2026-10-04T22:06:26.850Z", "repos": [ { "key": "tutorials-ims", @@ -8,6 +8,214 @@ } ], "entries": [ + { + "id": "tutorials-ims#2647", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2647, + "title": "fix(deploy): correct MCP mtar build flag (-f) in docs; parallelize asset-retention smoke", + "summary": "Corrected an internal deployment command and sped up a post-release health check that was intermittently reporting healthy deployments as failed.", + "category": "Fix", + "mergedAt": "2026-10-04T21:48:23Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2647" + }, + { + "id": "tutorials-ims#2646", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2646, + "title": "fix(auth): scope IAS to /a2a; restore XSUAA browser login (#2593 regression)", + "summary": "Restored sign-in on the developer portal after a recent change had broken logins for everyone on the DEV environment.", + "category": "Fix", + "mergedAt": "2026-10-04T20:44:45Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2646" + }, + { + "id": "tutorials-ims#2645", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2645, + "title": "fix(content): export quarantineIngestHandler from barrel index.js", + "summary": "Fixed a startup crash in the backend service that was blocking deployments.", + "category": "Fix", + "mergedAt": "2026-10-04T18:45:28Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2645" + }, + { + "id": "tutorials-ims#2644", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2644, + "title": "fix(db): add missing migration=5 for Users.pictureUrl", + "summary": "Added a missing database update so profile pictures can be stored, unblocking the deployment.", + "category": "Fix", + "mergedAt": "2026-10-04T18:00:27Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2644" + }, + { + "id": "tutorials-ims#2643", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2643, + "title": "feat(a2a): migrate agent auth from XSUAA client-credentials to SAP IAS (#2593)", + "summary": "Migrated the agent-to-agent integration endpoint to SAP's Identity Authentication sign-in, with existing tokens kept working through the transition.", + "category": "Feature", + "mergedAt": "2026-10-04T17:15:31Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2643" + }, + { + "id": "tutorials-ims#2642", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2642, + "title": "feat(#2586): surface rebuild quarantine outcome on Tutorials OP", + "summary": "The admin 'Rebuild this tutorial' action now honestly reports when a rebuild was blocked by validation instead of always showing success, with the reasons shown on the tutorial's page.", + "category": "Feature", + "mergedAt": "2026-10-04T16:38:32Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2642" + }, + { + "id": "tutorials-ims#2639", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2639, + "title": "feat(auth): login-provider profile picture (#2628)", + "summary": "Social logins can now supply a profile picture, with the SAP Community avatar taking precedence and initials shown as a fallback.", + "category": "Feature", + "mergedAt": "2026-10-04T16:15:17Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2639" + }, + { + "id": "tutorials-ims#2640", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2640, + "title": "feat(#2585): persistent quarantine — HANA persistence + Admin UI", + "summary": "Tutorials that fail pre-publish validation are now recorded centrally and listed in a new 'Quarantined Tutorials' admin screen, so blocked content is visible instead of lost.", + "category": "Feature", + "mergedAt": "2026-10-04T15:35:31Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2640" + }, + { + "id": "tutorials-ims#2638", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2638, + "title": "fix(test): stop semantic-search unit tests leaking via shared cds-caching store", + "summary": "Fixed an intermittently failing semantic-search test caused by a shared cache leaking between test runs.", + "category": "Fix", + "mergedAt": "2026-10-04T14:43:28Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2638" + }, + { + "id": "tutorials-ims#2635", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2635, + "title": "fix(mcp): pin attr.dbUserId on PAT synthetic user so resolvers resolve (#2630)", + "summary": "Fixed personal-access-token access to the hosted developer tools returning 'unable to resolve user' or silently empty results for valid tokens.", + "category": "Fix", + "mergedAt": "2026-10-04T14:00:57Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2635" + }, + { + "id": "tutorials-ims#2637", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2637, + "title": "fix(content): serve-side underscore-slug 404 + document 3b/3c republishes (#2587)", + "summary": "Fixed tutorials with underscores in their address returning 'not found' even though the content was published and healthy.", + "category": "Fix", + "mergedAt": "2026-10-04T13:48:56Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2637" + }, + { + "id": "tutorials-ims#2636", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2636, + "title": "docs: register signin-sso-reauth-followup in sidebar (fix Deploy Docs CI)", + "summary": "Registered a new operations page in the documentation sidebar so the docs site builds again.", + "category": "Docs", + "mergedAt": "2026-10-04T13:13:20Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2636" + }, + { + "id": "tutorials-ims#2634", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2634, + "title": "feat(signin): nudge to sign out before switching IdP over an active IAS session (#2626 follow-up)", + "summary": "The sign-in page now shows a hint to sign out first when you're already signed in and want to switch to a different login provider.", + "category": "Feature", + "mergedAt": "2026-10-04T12:55:31Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2634" + }, + { + "id": "tutorials-ims#2633", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2633, + "title": "fix(mcp): stop anonymous search surfacing deleted/unpublished tutorials (#2631)", + "summary": "Stopped anonymous tutorial search from returning deleted or unpublished tutorials whose links lead to a 'not found' page.", + "category": "Fix", + "mergedAt": "2026-10-04T12:48:29Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2633" + }, + { + "id": "tutorials-ims#2626", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2626, + "title": "docs: follow-up for /signin IdP re-selection over active IAS SSO session", + "summary": "Documented a known limitation where picking a provider on the sign-in page can reuse an existing session instead of switching providers, and the planned fix path.", + "category": "Docs", + "mergedAt": "2026-10-04T12:26:31Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2626" + }, + { + "id": "tutorials-ims#2632", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2632, + "title": "docs(auth): document shipped 5-provider social login + IAS admin setup", + "summary": "Brought the authentication documentation in line with the shipped five-provider social login and its admin-console setup steps.", + "category": "Docs", + "mergedAt": "2026-10-04T12:25:55Z", + "week": "2026-W40", + "weekStart": "2026-09-28", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2632" + }, { "id": "tutorials-ims#2576", "repo": "tutorials-ims",