From 57b55b3371272d50fa490ae78839f39908c576e8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 12:54:50 -0500 Subject: [PATCH 01/12] fix(admin): resolve feedback key from navigation path in mark/unmarkFeedbackIrrelevant (#3080) The markFeedbackIrrelevant / unmarkFeedbackIrrelevant actions are bound to TutorialFeedback, but the admin UI renders their buttons inside the feedbackItems LineItem on a Tutorials object page. That invokes them through the navigation path Tutorials(...)/feedbackItems()/markFeedbackIrrelevant, so req.params is [tutorialKey, feedbackKey]. The handler read req.params[0] (the tutorial key), so the feedback lookup never matched -> consistent 'Feedback entry not found' 404. Resolve the bound entity's own key from the LAST req.params entry, which works for both the direct single-key path and the two-key navigation path. Add regression tests covering the navigation-path invocation for both actions. --- srv/__tests__/tutorial-feedback.test.js | 40 +++++++++++++++++++++++++ srv/admin-service.js | 17 +++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/srv/__tests__/tutorial-feedback.test.js b/srv/__tests__/tutorial-feedback.test.js index 7f2914974..c8b04d6b2 100644 --- a/srv/__tests__/tutorial-feedback.test.js +++ b/srv/__tests__/tutorial-feedback.test.js @@ -319,4 +319,44 @@ describe('markFeedbackIrrelevant / unmarkFeedbackIrrelevant', () => { ); expect(status).toBe(404); }); + + // Regression for #3080: the admin UI renders the action button inside the + // feedbackItems LineItem on a Tutorials object page, so it invokes the + // action through the navigation path Tutorials(...)/feedbackItems(...)/ + // markFeedbackIrrelevant. In that case req.params carries BOTH keys + // ([tutorialKey, feedbackKey]); the handler must resolve the feedback row + // from its OWN (last) key, not req.params[0] (the tutorial key). + it('marks irrelevant via the Tutorials/feedbackItems navigation path (#3080)', async () => { + const { Tutorials, TutorialFeedback } = cds.entities('com.sap.developers.ims'); + const tutorialId = cds.utils.uuid(); + const navFeedbackId = cds.utils.uuid(); + await INSERT.into(Tutorials).entries([{ ID: tutorialId, slug: 'nav-demo-tutorial' }]); + await INSERT.into(TutorialFeedback).entries([ + { ID: navFeedbackId, tutorialSlug: 'nav-demo-tutorial', npsScore: 5 } + ]); + + const { status } = await project.post( + `/admin/Tutorials(ID=${tutorialId},IsActiveEntity=true)/feedbackItems(${navFeedbackId})/markFeedbackIrrelevant`, + { reason: 'Off-topic' }, + { ...auth, validateStatus: () => true } + ); + expect(status).toBe(200); + const [row] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: navFeedbackId }); + expect(row.isIrrelevant).toBe(true); + expect(row.irrelevantReason).toBe('Off-topic'); + + // and unmark via the same navigation path + const { status: unmarkStatus } = await project.post( + `/admin/Tutorials(ID=${tutorialId},IsActiveEntity=true)/feedbackItems(${navFeedbackId})/unmarkFeedbackIrrelevant`, + {}, + { ...auth, validateStatus: () => true } + ); + expect(unmarkStatus).toBe(200); + const [after] = await SELECT.from('com.sap.developers.ims.TutorialFeedback').where({ ID: navFeedbackId }); + expect(after.isIrrelevant).toBe(false); + expect(after.irrelevantReason).toBeNull(); + + await DELETE.from(TutorialFeedback).where({ ID: navFeedbackId }); + await DELETE.from(Tutorials).where({ ID: tutorialId }); + }); }); diff --git a/srv/admin-service.js b/srv/admin-service.js index 176ca1bb5..150171991 100644 --- a/srv/admin-service.js +++ b/srv/admin-service.js @@ -1829,10 +1829,23 @@ export default class AdminService extends cds.ApplicationService { }); // --- Feedback moderation --- + // + // #3080: the action is bound to TutorialFeedback, but the admin UI renders + // its button inside the feedbackItems LineItem on a Tutorials object page, + // so it is invoked through the navigation path + // Tutorials(...)/feedbackItems()/markFeedbackIrrelevant + // In that case req.params is [tutorialKey, feedbackKey] — the bound + // entity's own key is the LAST element, not req.params[0] (the tutorial + // key). Read the last param so BOTH the direct path (1 key) and the + // navigation path (2 keys) resolve the correct feedback row. + const feedbackKeyOf = (req) => { + const last = req.params[req.params.length - 1]; + return last?.ID ?? last; + }; this.on('markFeedbackIrrelevant', 'TutorialFeedback', async (req) => { const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); - const id = req.params[0]?.ID ?? req.params[0]; + const id = feedbackKeyOf(req); if (!id) return req.error(400, 'Missing entity key'); const row = await SELECT.one.from(TutorialFeedback).where({ ID: id }); if (!row) return req.error(404, 'Feedback entry not found'); @@ -1842,7 +1855,7 @@ export default class AdminService extends cds.ApplicationService { this.on('unmarkFeedbackIrrelevant', 'TutorialFeedback', async (req) => { const { TutorialFeedback } = cds.entities('com.sap.developers.ims'); - const id = req.params[0]?.ID ?? req.params[0]; + const id = feedbackKeyOf(req); if (!id) return req.error(400, 'Missing entity key'); const row = await SELECT.one.from(TutorialFeedback).where({ ID: id }); if (!row) return req.error(404, 'Feedback entry not found'); From ae27bf26c6f28c8d072a61e84583c020055a72d6 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:04:58 -0500 Subject: [PATCH 02/12] fix(attachments): correct base64url encode in render-link hook (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hugo's `replace` is `replace INPUT OLD NEW`, so the pipe chain `$b64 | replace "+" "-" | replace "/" "_" | replace "=" ""` appended $b64 as the third (NEW) argument at each step, searching the literal "+"/"/"/"=" and discarding the base64 entirely. The emitted attachment-source param collapsed to "u=/=/", which the server base64url-decoded to an invalid URL — ingest failed and every tutorial download link returned 404 "Attachment unavailable" (old and new tutorials alike). Use nested replace() calls with the string as the first positional arg. Add a behavioral regression test that renders the real hook through Hugo and asserts the u= param base64url-decodes back to the exact source URL; the previous string-grep test never rendered Hugo, which is why the bug shipped. --- .../layouts/_default/_markup/render-link.html | 9 +- .../render-link-attachment-encode.test.ts | 116 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 test/unit/render-link-attachment-encode.test.ts diff --git a/hugo/layouts/_default/_markup/render-link.html b/hugo/layouts/_default/_markup/render-link.html index 51cf7f2c1..d050cbbb2 100644 --- a/hugo/layouts/_default/_markup/render-link.html +++ b/hugo/layouts/_default/_markup/render-link.html @@ -11,9 +11,14 @@ {{- $isAttachment := and (hasPrefix $dest "https://raw.githubusercontent.com/") (in $exts $ext) -}} {{- if $isAttachment -}} {{- /* Encode the raw GitHub URL as base64url (no colons = WAF-safe). - Hugo's base64Encode produces standard base64; convert to base64url by replacing +/= with -_. */ -}} + Hugo's base64Encode produces standard base64; convert to base64url by replacing + → -, / → _, and stripping = padding. + NOTE: Hugo's `replace` is `replace INPUT OLD NEW`. It MUST be called with the + string as the FIRST positional arg — a pipe chain (`$b64 | replace "+" "-"`) + appends $b64 as the THIRD arg (NEW), so each step searches the literal "+"/"/"/"=" + and discards the base64 entirely, collapsing the param to "u=/=/" (issue #3098). + Keep these as nested calls, not a pipe chain. */ -}} {{- $b64 := $dest | base64Encode -}} -{{- $b64url := $b64 | replace "+" "-" | replace "/" "_" | replace "=" "" -}} +{{- $b64url := replace (replace (replace $b64 "+" "-") "/" "_") "=" "" -}} {{- $view := printf "/content/attachment-source?u=%s" $b64url -}} {{- $dl := printf "/content/attachment-source?u=%s&dl=1" $b64url -}} {{- /* $view/$dl are server-constructed, server-relative /content/attachment-source paths using base64url diff --git a/test/unit/render-link-attachment-encode.test.ts b/test/unit/render-link-attachment-encode.test.ts new file mode 100644 index 000000000..3ea202280 --- /dev/null +++ b/test/unit/render-link-attachment-encode.test.ts @@ -0,0 +1,116 @@ +/* eslint-disable */ +// Regression test for issue #3098 — tutorial file downloads broken ("Attachment +// unavailable"). +// +// The render-link hook (hugo/layouts/_default/_markup/render-link.html) routes +// raw.githubusercontent.com attachment links through the CAP attachment store at +// /content/attachment-source?u=. The source URL is +// base64url-encoded to dodge the Akamai WAF (#1931 follow-up, b82e4bb21). +// +// The encode used a PIPE chain to strip base64 padding/url-unsafe chars: +// {{ $b64 | replace "+" "-" | replace "/" "_" | replace "=" "" }} +// But Hugo's `replace` is `replace INPUT OLD NEW` — piping appends $b64 as the +// THIRD argument (NEW), so each step became replace("+","-",$b64) etc., which +// searches the literal "+"/"/"/"=" and discards the base64 entirely. The emitted +// param collapsed to a garbage "u=/=/", so the server base64url-decoded it to an +// empty/invalid URL, ingest failed, and the endpoint returned 404 +// "Attachment unavailable" for EVERY download link (old and new tutorials alike). +// +// Live symptom: https://developers.sap.com/tutorials/ai-core-genai-hana-vector +// step 2 download → {"error":"Attachment unavailable"}, even though the file at +// raw.githubusercontent.com/.../GRAPH_DOCU_2503.csv is reachable. +// +// This spins up a throwaway Hugo site with ONLY the real render-link hook and a +// content page containing an attachment link, renders it, extracts the emitted +// u= param, and asserts it base64url-decodes back to the exact source URL. +// The old string-grep test never rendered Hugo, which is why the bug shipped. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const HOOK = join(REPO_ROOT, 'hugo', 'layouts', '_default', '_markup', 'render-link.html'); + +// The exact link from issue #3098. +const SRC_URL = + 'https://raw.githubusercontent.com/SAP-samples/ai-core-samples/main/09_BusinessAIWeek/files/GRAPH_DOCU_2503.csv'; + +function hugoBinary(): string { + return process.env.HUGO || 'hugo'; +} + +let siteDir: string; +let rendered = ''; + +describe('render-link attachment hook encodes a decodable u= param (issue #3098)', () => { + beforeAll(() => { + siteDir = mkdtempSync(join(tmpdir(), 'render-link-attach-')); + mkdirSync(join(siteDir, 'layouts', '_default', '_markup'), { recursive: true }); + mkdirSync(join(siteDir, 'content'), { recursive: true }); + + // Use the REAL render-link hook from the repo — this is what we're guarding. + copyFileSync(HOOK, join(siteDir, 'layouts', '_default', '_markup', 'render-link.html')); + + writeFileSync(join(siteDir, 'hugo.toml'), + 'baseURL = "http://example.org/"\n' + + 'title = "t"\n' + + '[markup.goldmark.renderer]\n' + + 'unsafe = true\n'); + + writeFileSync(join(siteDir, 'layouts', '_default', 'single.html'), '{{ .Content }}\n'); + + writeFileSync(join(siteDir, 'content', 'page.md'), + '---\ntitle: p\n---\n\n' + + `The dataset in [GRAPH_DOCU_2503.csv](${SRC_URL}) includes vectors.\n`); + + const r = spawnSync(hugoBinary(), ['--source', siteDir], { + encoding: 'utf8', + timeout: 120_000, + }); + if (r.status !== 0) { + throw new Error( + `Hugo build failed (exit ${r.status})\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}` + ); + } + rendered = readFileSync(join(siteDir, 'public', 'page', 'index.html'), 'utf8'); + }, 130_000); + + afterAll(() => { + if (siteDir) rmSync(siteDir, { recursive: true, force: true }); + }); + + it('routes the attachment link through /content/attachment-source', () => { + expect(rendered).toContain('/content/attachment-source?u='); + expect(rendered).toContain('class="tutorial-attachment-link"'); + expect(rendered).toContain('class="tutorial-attachment-download"'); + }); + + it('does NOT emit the mangled "u=/=/" param (the #3098 bug)', () => { + expect(rendered).not.toContain('u=/=/'); + }); + + it('the view-link u= param base64url-decodes to the exact source URL', () => { + const m = rendered.match(/\/content\/attachment-source\?u=([^"&]+)"/); + expect(m, 'expected a view link with a u= param').toBeTruthy(); + const param = m![1]; + // base64url: no +, /, or = — all must have been converted/stripped. + expect(param).not.toMatch(/[+/=]/); + const decoded = Buffer.from(param, 'base64url').toString('utf8'); + expect(decoded).toBe(SRC_URL); + }); + + it('the download-link u= param base64url-decodes to the exact source URL', () => { + // The download sibling encodes to "...?u=&dl=1" in the HTML. + const m = rendered.match(/\/content\/attachment-source\?u=([^"&]+)&dl=1"/); + expect(m, 'expected a download link with a u= param and dl=1').toBeTruthy(); + const param = m![1]; + expect(param).not.toMatch(/[+/=]/); + const decoded = Buffer.from(param, 'base64url').toString('utf8'); + expect(decoded).toBe(SRC_URL); + }); +}); From 91a0275ca717b4126e05d764854c27e346fbeb0b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:39:43 -0500 Subject: [PATCH 03/12] fix(content): degrade missing-BLOB race to styled page, not raw 500 (#3103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content page loads (/concepts/:slug, /tutorials/:slug) intermittently returned a raw 500 "Internal Server Error". Root cause: serveStoredSlug reads BLOB metadata and the BLOB in two separate queries (to avoid the HANA LOB-locator-expiry gotcha). When metadata hits but the separate BLOB read returns nothing — the inconsistent-read window during a concurrent publish/delta-write to the mutable ContentCurrent, or a transient HANA locator blip — blobRow.CONTENT / toBuffer(null) threw, and serveHandler's catch surfaced raw JSON instead of the styled page. - serveStoredSlug: guard null/missing/non-Buffer/zero-length BLOB → return 'not-found' (styled 404); emit content.blob.missing metric. Both HANA and SQLite branches now coerce via toBuffer + share the guard. - serveHandler catch: serveNotFound(res, slug, 500) (styled page, 500 kept for monitoring) instead of raw JSON — mirrors the catalog branch and the fail-open pages path. - serveNotFound: no-store on 5xx so a transient blip served as a styled 500 is never edge-cached for 60s (404s stay cacheable). - resolveContentBlob: same toBuffer(null) guard in both branches, so the error-page/nav resolver can't throw on the identical race. Covers srv and srv-qa (both shim over @tutorials/content). Test: publish a slug, null its content column (metadata intact), assert styled 404 not 500. Red before, green after. --- packages/content/content-store.js | 37 ++++++++++++++++++++++++++----- test/lib/content-store.test.js | 20 +++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/content/content-store.js b/packages/content/content-store.js index 508ef8f51..1f5ca0a98 100644 --- a/packages/content/content-store.js +++ b/packages/content/content-store.js @@ -323,7 +323,8 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap } else { // slug-canonical: caller-canonicalizes const b = await SELECT.one.from(ContentCurrent).where({ slug }).columns('content'); - buffer = b ? await toBuffer(b.content) : null; + // #3103: toBuffer(null) throws — guard the raw column (same race as serveStoredSlug). + buffer = b?.content != null ? await toBuffer(b.content) : null; } return { contentHash: cur.contentHash, mimeType: cur.mimeType, version: cur.sourceVersion, buffer, source: 'current' }; } @@ -340,7 +341,8 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap } else { // slug-canonical: caller-canonicalizes const b = await SELECT.one.from(ContentFiles).where({ slug, version: meta.version }).columns('content'); - buffer = b ? await toBuffer(b.content) : null; + // #3103: toBuffer(null) throws — guard the raw column (same race as serveStoredSlug). + buffer = b?.content != null ? await toBuffer(b.content) : null; } return { contentHash: meta.contentHash, mimeType: meta.mimeType, version: meta.version, buffer, source: 'files' }; } @@ -942,7 +944,9 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap res.status(status); res.setHeader('Content-Type', `${resolved.mimeType}; charset=utf-8`); - res.setHeader('Cache-Control', 'public, max-age=60'); + // #3103: never let the edge cache a 5xx — a transient DB blip served as a + // styled 500 must not be pinned for 60s to later users. 404s stay cacheable. + res.setHeader('Cache-Control', status >= 500 ? 'no-store' : 'public, max-age=60'); res.setHeader('X-Content-Source', resolved.source === 'current' ? 'db-current' : 'db'); return res.send(decompressed); } catch (err) { @@ -1070,12 +1074,28 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap `SELECT TOP 1 "CONTENT" FROM "${hanaTableName()}" WHERE "SLUG" = ? AND "VERSION" = ?`, [slug, meta.version] ); - contentBuf = blobRow.CONTENT; + // Raw SQL returns CONTENT as a Buffer (see comment above), but coerce + // defensively via toBuffer so a stream/string LOB isn't mis-flagged as a + // miss by the Buffer.isBuffer guard below — symmetric with the else branch. + contentBuf = blobRow?.CONTENT != null ? await toBuffer(blobRow.CONTENT) : undefined; } else { const blobRow = source === 'current' ? await SELECT.one.from(ContentCurrent).where({ slug }).columns('content') // slug-canonical: caller-canonicalizes : await SELECT.one.from(ContentFiles).where({ slug, version: meta.version }).columns('content'); // slug-canonical: caller-canonicalizes - contentBuf = await toBuffer(blobRow.content); + // Guard the raw column before toBuffer — toBuffer(null) itself throws, + // which would bubble past the Buffer.isBuffer check below. + contentBuf = blobRow?.content != null ? await toBuffer(blobRow.content) : undefined; + } + // Resilience (#3103): the metadata SELECT above hit, but this SEPARATE BLOB + // read came back empty — the inconsistent-read window when a concurrent + // publish/delta-write to ContentCurrent (overwrite-in-place, no version + // pin) lands between the two queries, or a transient HANA locator blip. + // Treat a missing/non-Buffer BLOB as a soft miss → caller serves the styled + // 404, rather than letting `gunzipSync(undefined)` throw into the raw-500 + // catch and show the user "Internal Server Error". + if (!Buffer.isBuffer(contentBuf) || contentBuf.length === 0) { + metrics.counter('content.blob.missing'); // observability for the race + return 'not-found'; } const decompressed = gunzipSync(contentBuf); const advisory = await computeAdvisory(slug); @@ -1339,7 +1359,12 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap return serveNotFound(res, slug); } catch (err) { console.error('[content/serve]', err instanceof Error ? err.message : String(err)); - res.status(500).json({ error: 'Content retrieval failed' }); + metrics.counter('render.error'); // #3103: align with catalog-branch observability + // #3103: serve the styled error page (with 500 status for monitoring) + // instead of raw JSON "Content retrieval failed" — mirrors the catalog + // branch (line ~1260) and the fail-open pages path. A transient DB blip + // should never surface a bare "Internal Server Error" to the user. + return serveNotFound(res, slug, 500); } } diff --git a/test/lib/content-store.test.js b/test/lib/content-store.test.js index cb6925c26..1cfd73034 100644 --- a/test/lib/content-store.test.js +++ b/test/lib/content-store.test.js @@ -237,6 +237,26 @@ describe('content-store', () => { expect(res.headers['x-content-source']).toBe('cache'); }); + // Resilience: metadata row present but the BLOB column is empty/missing — + // the "metadata hit, BLOB read came back empty" race against a concurrent + // publish/delta-write to ContentCurrent. Must degrade to the styled 404, + // NOT bubble a TypeError into a raw 500 "Internal Server Error". See #3103. + it('returns the styled 404 (not a raw 500) when the BLOB is missing but metadata exists', async () => { + // Null out the content BLOB while leaving contentHash/version intact, + // reproducing the inconsistent-read window. + await UPDATE(ContentFiles).set({ content: null }).where({ slug: 'served-tut' }); + // Flush the LRU so the read hits the DB, not the warm cache. + const { invalidateContentCache } = await import('../../srv/lib/content-store.js'); + invalidateContentCache(); + + const res = await project.axios.get('/content/tutorials/served-tut', { + validateStatus: () => true + }); + + expect(res.status).not.toBe(500); + expect(res.status).toBe(404); + }); + it('redirects /tutorials/.html to canonical /tutorials/ with 301', async () => { const res = await project.axios.get('/content/tutorials/served-tut.html', { maxRedirects: 0, From f9426ea0cb47d0b8e10490577ba7bcdda713430c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:54:35 -0500 Subject: [PATCH 04/12] fix(cds-caching): atomic UPSERT in KeyvCDS.set to stop PK-collision error noise (#3102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeyvCDS.set did a read-then-create/update (TOCTOU). Under multi-instance CF two instances both read 'no row', both INSERT the same CacheStore key, and the second INSERT violates the PK unique index — logged at error level (the PROD kg:rl:* rate-limiter noise) with the losing write dropped. Replace that branch with an atomic single-key UPSERT (last-write-wins) via a committed patch-package patch against cds-caching@2.1.0. Add a regression test driving the real KeyvCDS against an in-memory CAP fixture, modelling the TOCTOU window deterministically. --- patches/cds-caching+2.1.0.patch | 22 +++++ .../keyv-cds-app/db/cache-store.cds | 10 +++ .../__fixtures__/keyv-cds-app/package.json | 9 ++ srv/lib/__tests__/keyv-cds-upsert.test.js | 84 +++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 patches/cds-caching+2.1.0.patch create mode 100644 srv/lib/__tests__/__fixtures__/keyv-cds-app/db/cache-store.cds create mode 100644 srv/lib/__tests__/__fixtures__/keyv-cds-app/package.json create mode 100644 srv/lib/__tests__/keyv-cds-upsert.test.js diff --git a/patches/cds-caching+2.1.0.patch b/patches/cds-caching+2.1.0.patch new file mode 100644 index 000000000..f92c8f43e --- /dev/null +++ b/patches/cds-caching+2.1.0.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/cds-caching/lib/support/KeyvCDS.js b/node_modules/cds-caching/lib/support/KeyvCDS.js +index 0000000..0000001 100644 +--- a/node_modules/cds-caching/lib/support/KeyvCDS.js ++++ b/node_modules/cds-caching/lib/support/KeyvCDS.js +@@ -63,12 +63,11 @@ + const { db, entity } = await this._getDB(); + if (!db || !entity) return; + const expiresAt = ttl ? Date.now() + ttl : null; +- const existing = await db.read(entity, key); +- if (existing) { +- await db.update(entity, key).with({ value, expiresAt }); +- } else { +- await db.create(entity, { ID: key, value, expiresAt }); +- } ++ // Atomic last-write-wins. CacheStore has a single-column PK (ID), ++ // so UPSERT compiles to HANA `UPSERT ... WITH PRIMARY KEY` and to ++ // SQLite `INSERT OR REPLACE` — no read-then-write race, no unique ++ // constraint collision under multi-instance concurrency (#3102). ++ await db.run(cds.ql.UPSERT.into(entity).entries({ ID: key, value, expiresAt })); + } catch (error) { + this.log.error(`KeyvCDS set error for key ${key}:`, error); + } diff --git a/srv/lib/__tests__/__fixtures__/keyv-cds-app/db/cache-store.cds b/srv/lib/__tests__/__fixtures__/keyv-cds-app/db/cache-store.cds new file mode 100644 index 000000000..dea39ead1 --- /dev/null +++ b/srv/lib/__tests__/__fixtures__/keyv-cds-app/db/cache-store.cds @@ -0,0 +1,10 @@ +namespace plugin.cds_caching; + +// Mirror of cds-caching/db/cache-store.cds — minimal fixture so the real +// KeyvCDS adapter can resolve `plugin.cds_caching.CacheStore` and get a +// backing table under cds.test(). Keep the shape in sync with the plugin. +entity CacheStore { + key ID : String(900); + value : LargeString; + expiresAt : Int64; +} diff --git a/srv/lib/__tests__/__fixtures__/keyv-cds-app/package.json b/srv/lib/__tests__/__fixtures__/keyv-cds-app/package.json new file mode 100644 index 000000000..d2dd267fe --- /dev/null +++ b/srv/lib/__tests__/__fixtures__/keyv-cds-app/package.json @@ -0,0 +1,9 @@ +{ + "name": "keyv-cds-fixture", + "private": true, + "cds": { + "requires": { + "db": { "kind": "sqlite", "credentials": { "url": ":memory:" } } + } + } +} diff --git a/srv/lib/__tests__/keyv-cds-upsert.test.js b/srv/lib/__tests__/keyv-cds-upsert.test.js new file mode 100644 index 000000000..5ca481589 --- /dev/null +++ b/srv/lib/__tests__/keyv-cds-upsert.test.js @@ -0,0 +1,84 @@ +// srv/lib/__tests__/keyv-cds-upsert.test.js +// +// Regression guard for issue #3102: cds-caching's KeyvCDS.set() did a +// read-then-create/update (TOCTOU). Under multi-instance CF two instances +// both read "no row", both INSERT the same CacheStore key, and the second +// INSERT violates the PK unique index — which the adapter logs at ERROR +// level (the PROD noise) and the losing write is dropped. The committed +// patches/cds-caching+2.1.0.patch replaces that branch with an atomic +// single-key UPSERT (last-write-wins): no collision, no error log, no lost +// write. +// +// A single in-process SQLite test can't naturally race two HANA instances, +// so we model the TOCTOU window deterministically: stub the adapter's +// db.read to return undefined for the key (the window where a peer's INSERT +// is not yet visible to this reader) while the row already physically +// exists. Old code then takes the INSERT branch and collides; new code +// UPSERTs cleanly. + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { createRequire } from 'node:module'; +import cds from '@sap/cds'; + +const require = createRequire(import.meta.url); +const KeyvCDS = require('cds-caching/lib/support/KeyvCDS.js'); + +cds.test(import.meta.dirname + '/__fixtures__/keyv-cds-app'); + +describe('KeyvCDS.set is collision-safe (issue #3102)', () => { + let entity; + beforeAll(() => { + entity = cds.entities('plugin.cds_caching').CacheStore; + }); + + it('serial re-set of the same key overwrites (last-write-wins)', async () => { + const store = new KeyvCDS({}); + await store.set('dup', 'first', 10_000); + await store.set('dup', 'second', 10_000); + expect(await store.get('dup')).toBe('second'); + }); + + it('re-set with no TTL overwrites cleanly (expiresAt null)', async () => { + const store = new KeyvCDS({}); + await store.set('nt', 'a', 10_000); + await store.set('nt', 'b'); // no ttl + expect(await store.get('nt')).toBe('b'); + const row = await cds.db.run(SELECT.one.from(entity).where({ ID: 'nt' })); + expect(row.expiresAt).toBeNull(); + }); + + // The decisive test. Reproduces the multi-instance TOCTOU on one process: + // the row already exists in the DB, but this adapter's read is forced to + // miss it (the race window). Pre-patch INSERT-branch collides on the PK and + // logs 'KeyvCDS set error'; post-patch UPSERT persists the value with no + // error log. + it('a write into an already-occupied key (race window) does not collide and persists', async () => { + const key = 'kg:rl:race:win'; + // Peer instance already INSERTed the row. + await cds.db.run( + INSERT.into(entity).entries({ ID: key, value: 'peer', expiresAt: Date.now() + 10_000 }), + ); + + const store = new KeyvCDS({}); + const logSpy = vi.spyOn(store.log, 'error').mockImplementation(() => {}); + // Force the read inside set() to miss (TOCTOU window). + const realRead = cds.db.read.bind(cds.db); + const readSpy = vi.spyOn(cds.db, 'read').mockImplementation(async (ent, k) => { + if (k === key) return undefined; + return realRead(ent, k); + }); + + try { + await store.set(key, 'mine', 10_000); + } finally { + readSpy.mockRestore(); + } + + // Patched (UPSERT): no error logged, value overwritten to 'mine'. + const setErrors = logSpy.mock.calls.filter((c) => String(c[0]).includes('set error')); + expect(setErrors).toHaveLength(0); + const row = await cds.db.run(SELECT.one.from(entity).where({ ID: key })); + expect(row.value).toBe('mine'); + logSpy.mockRestore(); + }); +}); From 999f1486ca1577f97aca50e6c9685cd23a49a2e9 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:55:53 -0500 Subject: [PATCH 05/12] build: wire patch-package postinstall for cds-caching UPSERT patch (#3102) Add patch-package devDep + a postinstall hook so the cds-caching@2.1.0 UPSERT patch re-applies on every npm ci / npm install. prepare (git hooks) is left intact; both hooks fire on install. --- package-lock.json | 224 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 + 2 files changed, 226 insertions(+) diff --git a/package-lock.json b/package-lock.json index b40da7d93..c895dc736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "esbuild": "0.28.2", "fundamental-styles": "^0.41.7", "happy-dom": "20.14.3", + "patch-package": "8.0.1", "playwright-core": "^1.61.1", "postcss": "^8.5.16", "postcss-cli": "^11.0.1", @@ -11871,6 +11872,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/@zxing/library": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", @@ -12729,6 +12737,25 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "license": "MIT", @@ -13054,6 +13081,22 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cjs-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", @@ -14269,6 +14312,24 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -15524,6 +15585,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/findit2": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/findit2/-/findit2-2.2.3.tgz", @@ -16004,6 +16075,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "license": "MIT", @@ -16977,6 +17061,33 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -16995,6 +17106,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -17103,6 +17224,16 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kuler": { "version": "2.0.0", "license": "MIT" @@ -18625,6 +18756,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.1.1", "dev": true, @@ -18902,6 +19043,71 @@ "node": ">= 0.4.0" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -20341,6 +20547,24 @@ "version": "2.0.0", "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", diff --git a/package.json b/package.json index 806fc2f62..565adfc55 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "build:island-manifest": "node scripts/build-island-manifest.cjs", "check:ui5-single-copy": "node scripts/check-ui5-single-copy.cjs", "retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json", + "postinstall": "patch-package", "prepare": "npm run setup:git-hooks", "setup:git-hooks": "sh -c 'if [ -f scripts/install-git-hooks.sh ]; then sh scripts/install-git-hooks.sh; fi'", "static-guards": "tsx scripts/run-static-guards.ts", @@ -168,6 +169,7 @@ "esbuild": "0.28.2", "fundamental-styles": "^0.41.7", "happy-dom": "20.14.3", + "patch-package": "8.0.1", "playwright-core": "^1.61.1", "postcss": "^8.5.16", "postcss-cli": "^11.0.1", From d61f30e1abacd555948346d6e6aa3adfa4248821 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:56:40 -0500 Subject: [PATCH 06/12] fix(content-serve): degrade missing-BLOB race to styled 404, not raw 500 (#3103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding defects on /tutorials/:slug and /concepts/:slug serve paths: 1. serveStoredSlug read the BLOB metadata and the BLOB itself in separate queries (correct — avoids HANA LOB-locator-expiry). During a concurrent delta-write to mutable ContentCurrent (overwrite-in-place, no version pin) or a transient HANA locator blip the metadata row could survive while the BLOB SELECT returned null/undefined, causing toBuffer(null) or blobRow.CONTENT dereference to throw. 2. serveHandler's catch returned res.status(500).json({error:'Content retrieval failed'}) — raw JSON. All sibling branches (pageServeHandler, authorServeHandler, advocateServeHandler) already call serveNotFound; this path was the outlier. Fix: - Guard the BLOB read: null/undefined/non-Buffer/zero-length contentBuf → return 'not-found' (styled 404). Emits content.blob.missing metric for observability of the race condition. - Align serveHandler catch with siblings: serveNotFound(res, slug, 500) (styled page, status 500 preserved for monitoring). Test: publish a slug, null its content column (metadata intact), assert HTTP 404 not 500. Red before, green after. --- packages/content/content-store.js | 19 ++++++++++++++++--- test/lib/content-store.test.js | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/content/content-store.js b/packages/content/content-store.js index 508ef8f51..fdf331fd0 100644 --- a/packages/content/content-store.js +++ b/packages/content/content-store.js @@ -1070,12 +1070,22 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap `SELECT TOP 1 "CONTENT" FROM "${hanaTableName()}" WHERE "SLUG" = ? AND "VERSION" = ?`, [slug, meta.version] ); - contentBuf = blobRow.CONTENT; + contentBuf = blobRow?.CONTENT; } else { const blobRow = source === 'current' ? await SELECT.one.from(ContentCurrent).where({ slug }).columns('content') // slug-canonical: caller-canonicalizes : await SELECT.one.from(ContentFiles).where({ slug, version: meta.version }).columns('content'); // slug-canonical: caller-canonicalizes - contentBuf = await toBuffer(blobRow.content); + contentBuf = blobRow?.content != null ? await toBuffer(blobRow.content) : null; + } + // #3103: missing-BLOB race. The metadata SELECT above and this BLOB SELECT + // are separate queries (deliberately — CDS QL LOB locators expire). During + // a concurrent delta-write to the mutable ContentCurrent (overwrite-in- + // place, no version pin) or a transient HANA locator blip, the metadata + // row can survive while the BLOB comes back null/empty. Degrade to a styled + // 404 instead of throwing a raw 500 (sibling serve branches all fail open). + if (!Buffer.isBuffer(contentBuf) || contentBuf.length === 0) { + metrics.counter('content.blob.missing'); // #3103 + return 'not-found'; } const decompressed = gunzipSync(contentBuf); const advisory = await computeAdvisory(slug); @@ -1339,7 +1349,10 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap return serveNotFound(res, slug); } catch (err) { console.error('[content/serve]', err instanceof Error ? err.message : String(err)); - res.status(500).json({ error: 'Content retrieval failed' }); + // #3103: align with sibling serve branches (pageServeHandler, authorServeHandler, + // advocateServeHandler) — all call serveNotFound on unexpected errors. Raw JSON + // 500 was the outlier; keep status=500 for monitoring but serve the styled page. + return serveNotFound(res, slug, 500); } } diff --git a/test/lib/content-store.test.js b/test/lib/content-store.test.js index cb6925c26..efbde1b33 100644 --- a/test/lib/content-store.test.js +++ b/test/lib/content-store.test.js @@ -229,6 +229,27 @@ describe('content-store', () => { expect(res.status).toBe(503); }); + // #3103: missing-BLOB race. The serve path reads BLOB metadata and the BLOB + // itself in two separate queries (to dodge the HANA LOB-locator-expiry + // gotcha). A concurrent delta-write to the mutable ContentCurrent (or a + // transient HANA locator blip) can land the metadata SELECT on a row whose + // separate BLOB SELECT comes back null. That must degrade to a styled 404, + // NOT a raw JSON 500 (the tutorial/concept path was the only serve branch + // that threw on this; its siblings all fail open). + it('degrades to a 404 (not a raw 500) when the BLOB is null but metadata survives', async () => { + // Null the content column while leaving the metadata row (contentHash, + // mimeType, version) intact — mimics the inconsistent-read window. + await UPDATE(ContentFiles).set({ content: null }).where({ slug: 'served-tut' }); + + const res = await project.axios.get('/content/tutorials/served-tut', { + validateStatus: () => true + }); + + // Red before the fix: serveStoredSlug throws (toBuffer(null) / gunzipSync), + // serveHandler's catch returns raw 500 JSON. + expect(res.status).toBe(404); + }); + it('serves from cache on second request', async () => { await project.axios.get('/content/tutorials/served-tut'); const res = await project.axios.get('/content/tutorials/served-tut'); From 74ba3d064d277314cb769acd3eb3c08b8a02c952 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:57:54 -0500 Subject: [PATCH 07/12] docs(cds-caching): record KeyvCDS.set UPSERT patch + upgrade path (#3102) --- CLAUDE.md | 2 +- .../developers/reference/cds-caching-store.md | 57 +++++ .../2026-11-11-cds-caching-upsert-fix.md | 231 ++++++++++++++++++ 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-11-11-cds-caching-upsert-fix.md diff --git a/CLAUDE.md b/CLAUDE.md index 49dfaa6ed..755f2ea74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,7 +152,7 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **`@cap-js/ai` for RPT-1 ValueList recommendations (#959)** — `AICore-mocked` locally, `AICore-btp` in hybrid/prod; per-field opt-out `@UI.RecommendationState: 0`. Ref: [cap-ai-plugin.md](docs/developers/reference/cap-ai-plugin.md). - **Knowledge-graph feature flags (`KG_*`), all default OFF + DEV-only, all fail-open** — PageRank #916, WCC isolation #918, on-demand extraction #948, Louvain communities #917, orphan retirement #1115, community peers/labels #1126, community search weight #1171, coverage nudge #1172, cluster Q&A #1173. Toggles, nightly jobs, and fail-open specifics → gotchas.md "Knowledge graph feature flags". - **HCQL protocol adapter (#2247, CAP 10.1.0)** — `POST /hcql/` paths (NOT co-mounted on OData URLs) on 5 authenticated services only (`AdminService`, `AuthorService`, `AnalyticsService`, `ExportsService`, `ConsolidationService`); requires `@sap/cds >= 10.1.0`. Malformed-CQN process-exit DoS from 10.0.3 **fixed in 10.1.0** (now returns 400, server stays up). Auth enforced before CQN parse. Kill: delete `srv/hcql-enablement.cds` + drop the `hcql` entry from `AdminService`'s `@protocol` list in `srv/admin-service-mcp.cds`, then `cds build --production` + redeploy. Ref: [hcql-support.md](docs/developers/reference/hcql-support.md). -- **cds-caching CDS-DB store + metrics ON (#1222)** — `store:"cds"` in hybrid/prod, `memory` in base/unit. CF resolve-guard needs BOTH the baked csn entities AND `srv/lib/strip-precompiled-plugin-roots.js` (both load-bearing). Ref: [cds-caching-store.md](docs/developers/reference/cds-caching-store.md). +- **cds-caching CDS-DB store + metrics ON (#1222)** — `store:"cds"` in hybrid/prod, `memory` in base/unit. CF resolve-guard needs BOTH the baked csn entities AND `srv/lib/strip-precompiled-plugin-roots.js` (both load-bearing). A committed `patch-package` patch (`patches/cds-caching+2.1.0.patch`, `postinstall`) makes `KeyvCDS.set` an atomic UPSERT to kill PROD PK-collision error noise on concurrent `kg:rl:*` writes (#3102); **re-verify the patch on any `cds-caching` bump** (patch-package fails install loudly on drift). Ref: [cds-caching-store.md](docs/developers/reference/cds-caching-store.md). - **User-facing UI changes want a committed e2e spec** — advisory PR nudge on `app/**`/`hugo/**` changes; real coverage runs in the post-DEV-deploy `e2e` job. Ref: [e2e-coverage-pattern.md](docs/developers/reference/e2e-coverage-pattern.md). - **`test:e2e` is post-deploy only, not on PRs** — self-skips without `SMOKE_BASE_URL`. Served tutorials render `
`+`

`, NOT `
`. Runbook: `test/e2e/README.md`. - **Freshness detector grounding needs the corpus-embedding backfill** — until `srv/jobs/freshness-corpus-embedding-job.js` runs, every API-obsolescence claim degrades to `confidence: Low`. Tutorial source from `ContentFiles.sourceContent` via `getTutorialSource(slug)`, NOT `Steps.description`. → gotchas.md "Freshness detector". diff --git a/docs/developers/reference/cds-caching-store.md b/docs/developers/reference/cds-caching-store.md index b325a8634..bed701271 100644 --- a/docs/developers/reference/cds-caching-store.md +++ b/docs/developers/reference/cds-caching-store.md @@ -356,3 +356,60 @@ command-palette concept search): `@cache: { ttl: 300000, tags: [{ value: re-enabled metrics actually accumulate against the deployed DEV container (clear the stale zeroed rows first), then let PROD traffic post-cutover give the baseline before annotating more surfaces. + +## `KeyvCDS.set` UPSERT patch (#3102) + +**Symptom (PROD, 2026-11-11):** recurring `level:"error"` lines from the +`cds-caching` logger: + +``` +KeyvCDS set error for key kg:rl:api:auth:auth:: unique constraint violated: +Table(PLUGIN_CDS_CACHING_CACHESTORE), Index(_SYS_TREE_CS_#..._#P0) ... key: ID=kg:rl:... +``` + +Two namespaces seen — both the KG rate-limiter (`srv/lib/rate-limit/shared-limiter.js`, +key prefix `kg:rl:`): `kg:rl:api:auth:auth:` and `kg:rl:homepage:anon:`. + +**Root cause:** the plugin's CDS-DB store adapter, +`node_modules/cds-caching/lib/support/KeyvCDS.js`, implemented `set()` as a +**read-then-create/update** (TOCTOU): + +```js +const existing = await db.read(entity, key); +if (existing) { await db.update(entity, key).with({ value, expiresAt }); } +else { await db.create(entity, { ID: key, value, expiresAt }); } +``` + +Under multi-instance (HA) CF, two instances both read "no row" for the same +`kg:rl:*` counter, both take the `else` branch, and the second `create` violates +the `CacheStore` PK unique index. The adapter's own `catch` logs at **error** +level and swallows the exception — so the collision never reaches our +`shared-limiter.js` fail-open (the limiter already over-allows under races, which +is acceptable), but it spams PROD error monitoring and the losing write is +dropped. + +**Fix:** a committed **patch-package** patch, +`patches/cds-caching+2.1.0.patch`, replaces that branch with a single atomic +UPSERT: + +```js +await db.run(cds.ql.UPSERT.into(entity).entries({ ID: key, value, expiresAt })); +``` + +`CacheStore` has a single-column PK (`ID : String(900)`), so UPSERT compiles to +HANA `UPSERT ... WITH PRIMARY KEY` and to SQLite `INSERT OR REPLACE` — +last-write-wins, no read-then-write race, no unique-constraint collision. The +patch re-applies on every `npm ci` / `npm install` via the `postinstall` hook +(`patch-package`). Regression test: +`srv/lib/__tests__/keyv-cds-upsert.test.js` (drives the real `KeyvCDS` against an +in-memory CAP fixture, modelling the TOCTOU window deterministically). + +**Upstream:** the same read-then-insert is still present on `cds-caching` `main`. +Filed upstream as an issue + PR against +[mikezaschka/cds-caching](https://github.com/mikezaschka/cds-caching) (links in +the #3102 PR). **Drop condition:** once a published `cds-caching` release carries +the upstream fix and we adopt it, delete `patches/cds-caching+2.1.0.patch`. +**Re-verify the patch on any `cds-caching` version bump** — `patch-package` +fails the install loudly if the target file drifted, so the drift surfaces at +`npm ci`, not silently. + diff --git a/docs/superpowers/plans/2026-11-11-cds-caching-upsert-fix.md b/docs/superpowers/plans/2026-11-11-cds-caching-upsert-fix.md new file mode 100644 index 000000000..6e8317b54 --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-cds-caching-upsert-fix.md @@ -0,0 +1,231 @@ +# cds-caching UPSERT Fix (#3102) 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:** Stop `cds-caching` from logging `error`-level unique-constraint-violation noise in PROD when two HA instances concurrently write the same cache key (notably `kg:rl:*` rate-limiter counters), by making the `KeyvCDS.set()` write path an atomic single-key UPSERT. + +**Architecture:** The defect lives in the vendored `cds-caching` plugin (`node_modules/cds-caching/lib/support/KeyvCDS.js`), whose `set()` does read-then-`create`/`update` — a TOCTOU race that collides on the `CacheStore` PK under multi-instance CF. We fix it two ways: (1) **locally and immediately** via a committed `patch-package` patch against our pinned `cds-caching@2.1.0`, replacing the racy branch with `UPSERT.into(entity).entries(...)` (last-write-wins, no collision); (2) **upstream** by filing an issue + PR against `mikezaschka/cds-caching` so the patch can eventually be dropped. The bug is confirmed still present on upstream `main`. + +**Tech Stack:** Node.js 22, `@sap/cds` (CQL `UPSERT`), `cds-caching@2.1.0`, `patch-package`, HANA (prod/hybrid) / SQLite (unit). + +**Spec:** GitHub issue [sap-tutorials/tutorials-ims#3102](https://github.com/sap-tutorials/tutorials-ims/issues/3102) + +## Global Constraints + +- `cds-caching` is pinned at exactly `"2.1.0"` in `package.json` dependencies — the patch filename and `patch-package` match this version verbatim; do **not** bump it as part of this fix. +- Node engine is pinned `22.23.4`; `patch-package` must run under it. +- No raw SQL — use CQL `UPSERT` (`@sap/cds`), consistent with the CAP constraint in CLAUDE.md. +- The `CacheStore` entity is single-PK: `key ID : String(900); value : LargeString; expiresAt : Int64;`. UPSERT semantics are only safe here because the key is a single PK column (composite-key UPSERT on HANA via CDS QL is a known gotcha — not applicable). +- The fix must remain **fail-open**: `set()` must still never throw to its caller (keep the existing `try/catch` returning normally). +- `patch-package` must re-apply automatically on `npm ci` / `npm install` (CI uses `npm ci`), via a `postinstall` hook, without breaking the existing `prepare` script. +- The MCP MTA (`tutorials-mcp`) and QA app reuse the same `srv/lib` tree but **not** `node_modules` patching concerns — no `srv-qa` cp-list change is needed (no new `srv/lib/` file). + +## Review Focus + +- **npm script collision:** the repo has `prepare` (git hooks) but no `postinstall`. Adding `postinstall: patch-package` must not clobber `prepare`; both must run on install. — covered in Task 2 (verify `npm ci` output shows both hooks firing). +- **Patch fails to apply after a future `cds-caching` bump:** `patch-package` errors (non-zero) if the target file drifted. Task 2 pins the exact `2.1.0` and documents the drop-on-upgrade path; the drift surfaces loudly at install, not silently. +- **UPSERT on SQLite (unit profile):** unit tests use in-memory SQLite with `store:"memory"` (not `cds`), so KeyvCDS is never exercised there; the patch's UPSERT path is only hit under `store:"cds"`. Task 1 adds a direct KeyvCDS test against an in-memory SQLite db to prove UPSERT works on *both* dialects and that a double-set of the same key does not throw. +- **`value`/`expiresAt` null handling:** a `set` with no TTL stores `expiresAt: null`; UPSERT must preserve that (not drop the column). Task 1 asserts a re-set with a shorter/absent TTL overwrites cleanly. +- **Concurrent set does not lose the row entirely:** after two racing sets, the key must still be readable (one winner), never left absent. Task 1 asserts the key exists post-double-set. + +--- + +### Task 1: patch-package patch — atomic UPSERT in `KeyvCDS.set()` (+ regression test) + +**Files:** +- Create: `patches/cds-caching+2.1.0.patch` (generated by `patch-package`, not hand-written) +- Modify (as patch source): `node_modules/cds-caching/lib/support/KeyvCDS.js` — the `set(key, value, ttl)` method body +- Test: `srv/lib/__tests__/keyv-cds-upsert.test.js` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: a committed `patches/cds-caching+2.1.0.patch` that Task 2 wires into install; the behavioral guarantee that `KeyvCDS.set()` is collision-free and still never throws. + +- [ ] **Step 1: Write the failing test** + +Create `srv/lib/__tests__/keyv-cds-upsert.test.js`. It instantiates the real `KeyvCDS` class from `cds-caching`, backed by an in-memory SQLite `cds.db` with the `plugin.cds_caching.CacheStore` entity deployed, and asserts the collision-safe contract: + +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import KeyvCDS from 'cds-caching/lib/support/KeyvCDS.js'; + +describe('KeyvCDS.set is collision-safe (issue #3102)', () => { + beforeAll(async () => { + // deploy only the cache-store entity into an in-memory sqlite db + cds.model = await cds.load([require.resolve('cds-caching/db/cache-store.cds')]); + await cds.deploy(cds.model).to('sqlite::memory:'); + }); + + it('a second set of the same key does NOT throw and overwrites (last-write-wins)', async () => { + const store = new KeyvCDS({}); + await store.set('dup', { count: 1 }, 10_000); + await expect(store.set('dup', { count: 2 }, 10_000)).resolves.not.toThrow(); + expect(await store.get('dup')).toEqual({ count: 2 }); + }); + + it('two concurrent sets of a fresh key both resolve and the key is present', async () => { + const store = new KeyvCDS({}); + await Promise.all([store.set('race', { a: 1 }, 10_000), store.set('race', { a: 2 }, 10_000)]); + expect(await store.get('race')).toBeDefined(); + }); + + it('re-set with no TTL overwrites cleanly (expiresAt null)', async () => { + const store = new KeyvCDS({}); + await store.set('nt', 'first', 10_000); + await store.set('nt', 'second'); // no ttl + expect(await store.get('nt')).toBe('second'); + }); +}); +``` + +The decisive assertion is that a simulated collision does not surface. (On SQLite a true PK re-INSERT would throw `UNIQUE constraint failed`; the pre-patch read-then-create code path can hit it under interleaving. Post-patch UPSERT never does.) + +- [ ] **Step 2: Run the test against the UNPATCHED plugin to see it is not yet collision-proof** + +Run: `npm test -- srv/lib/__tests__/keyv-cds-upsert.test.js` +Expected: the concurrent-set / double-set assertions fail or emit the `KeyvCDS set error` log (pre-patch read-then-create path). If the unpatched code happens to pass serially, note it — the patch's value is proven by the HANA PK semantics; keep the test as the post-patch regression guard. + +- [ ] **Step 3: Edit `node_modules/cds-caching/lib/support/KeyvCDS.js`** + +Replace the body of `async set(key, value, ttl)` so the read-then-`create`/`update` branch becomes a single atomic UPSERT, keeping the outer `try/catch` (fail-open, no re-throw) intact: + +```js +async set(key, value, ttl) { + try { + const { db, entity } = await this._getDB(); + if (!db || !entity) return; + const expiresAt = ttl ? Date.now() + ttl : null; + // Atomic last-write-wins. CacheStore has a single-column PK (ID), + // so UPSERT compiles to HANA `UPSERT ... WITH PRIMARY KEY` and to + // SQLite `INSERT OR REPLACE` — no read-then-write race, no unique + // constraint collision under multi-instance concurrency (#3102). + await db.run(UPSERT.into(entity).entries({ ID: key, value, expiresAt })); + } catch (error) { + this.log.error(`KeyvCDS set error for key ${key}:`, error); + } +} +``` + +`UPSERT` is a CQL global provided by `@sap/cds`; `cds` is already required at the top of the file (`const cds = require('@sap/cds')`), and `UPSERT` is exposed as a global by the cds runtime. If `UPSERT` is not in scope in this module, reference it as `cds.ql.UPSERT`. Confirm which form resolves before generating the patch. + +- [ ] **Step 4: Run the test to verify it passes against the patched plugin** + +Run: `npm test -- srv/lib/__tests__/keyv-cds-upsert.test.js` +Expected: PASS, no `KeyvCDS set error` log emitted. + +- [ ] **Step 5: Generate the patch file** + +Run: `npx patch-package cds-caching` +Expected: creates `patches/cds-caching+2.1.0.patch` containing only the `set()` diff. Inspect it to confirm it touches exactly that method and nothing else. + +- [ ] **Step 6: Commit** + +```bash +git add patches/cds-caching+2.1.0.patch srv/lib/__tests__/keyv-cds-upsert.test.js +git commit -m "fix(cds-caching): atomic UPSERT in KeyvCDS.set to stop PK-collision error noise (#3102)" +``` + +--- + +### Task 2: Wire `patch-package` into install + +**Files:** +- Modify: `package.json` — add `patch-package` to `devDependencies`, add/extend a `postinstall` script. + +**Interfaces:** +- Consumes: `patches/cds-caching+2.1.0.patch` from Task 1. +- Produces: automatic patch re-application on `npm ci` / `npm install`. + +- [ ] **Step 1: Add the dev dependency** + +Run: `npm install --save-dev patch-package` +Expected: `patch-package` appears in `devDependencies`. (It also pulls its own deps; confirm the install succeeds under Node 22.) + +- [ ] **Step 2: Add the `postinstall` hook** + +In `package.json` `scripts`, add: +```json +"postinstall": "patch-package" +``` +Leave the existing `prepare` script untouched. (npm runs `postinstall` then `prepare` on a bare `npm install`; both fire.) + +- [ ] **Step 3: Verify the patch applies from a clean install** + +Run: `npm ci` +Expected: install output includes a `patch-package` line applying `cds-caching@2.1.0` cleanly; `node_modules/cds-caching/lib/support/KeyvCDS.js` contains the UPSERT edit afterward. Confirm with: `grep -c "UPSERT.into" node_modules/cds-caching/lib/support/KeyvCDS.js` → `1`. + +- [ ] **Step 4: Re-run the regression test post-`npm ci`** + +Run: `npm test -- srv/lib/__tests__/keyv-cds-upsert.test.js` +Expected: PASS (proves the patch survives a real install cycle). + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json +git commit -m "build: wire patch-package postinstall for cds-caching UPSERT patch (#3102)" +``` + +--- + +### Task 3: Document the patch + upgrade path + +**Files:** +- Modify: `docs/developers/reference/cds-caching-store.md` — add a short "KeyvCDS.set UPSERT patch (#3102)" section. +- Modify: `CLAUDE.md` — one line in the cds-caching gotcha bullet noting the committed patch and that it must be re-verified on any `cds-caching` bump. + +**Interfaces:** +- Consumes: the patch + wiring from Tasks 1–2. +- Produces: operator-facing record of why the patch exists and how to drop it. + +- [ ] **Step 1: Add the reference-doc section** + +Append to `docs/developers/reference/cds-caching-store.md` a section explaining: the PROD symptom (`error`-level `unique constraint violated` on `PLUGIN_CDS_CACHING_CACHESTORE` from concurrent `kg:rl:*` writes), the root cause (read-then-insert TOCTOU in `KeyvCDS.set`), the fix (committed `patches/cds-caching+2.1.0.patch` → atomic UPSERT), the upstream issue/PR links (Task 4), and the drop condition (patch becomes unnecessary once a `cds-caching` release carrying the upstream fix is adopted — re-verify/remove the patch on any version bump). + +- [ ] **Step 2: Add the CLAUDE.md note** + +Extend the existing `cds-caching CDS-DB store` gotcha bullet with: a committed `patch-package` patch (`patches/cds-caching+2.1.0.patch`) makes `KeyvCDS.set` an atomic UPSERT to kill PROD PK-collision error noise (#3102); re-verify the patch on any `cds-caching` upgrade. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/reference/cds-caching-store.md CLAUDE.md +git commit -m "docs(cds-caching): record KeyvCDS.set UPSERT patch + upgrade path (#3102)" +``` + +--- + +### Task 4: Upstream the fix (issue + PR to mikezaschka/cds-caching) + +**Files:** none in this repo (external repo). This task produces links recorded in Task 3's docs — run Task 4 before Task 3 Step 1 if you want the links inline, or update the doc afterward. + +**Interfaces:** +- Consumes: the diagnosis + UPSERT diff from Task 1 (the upstream patch is the same edit applied to `main`, where `set()` is byte-identical). +- Produces: an upstream issue URL and PR URL. + +- [ ] **Step 1: File the upstream issue** + +Against `mikezaschka/cds-caching`: title ≈ "KeyvCDS.set read-then-insert races on PK under multi-instance → unique-constraint errors". Body: the HA race, the `PLUGIN_CDS_CACHING_CACHESTORE` collision, that `set()` on `main` still does read-then-`create`/`update`, and the proposed atomic-UPSERT fix. Confirm no existing open issue covers it first. + +- [ ] **Step 2: Open the upstream PR** + +Fork `mikezaschka/cds-caching`, branch, apply the same `set()` UPSERT edit to `lib/support/KeyvCDS.js` on `main`, and open a PR referencing the issue. Match upstream code style (4-space indent, the existing comment tone). Include a one-line note that single-PK `CacheStore` makes UPSERT safe on HANA + SQLite + Postgres. + +- [ ] **Step 3: Record the links** + +Paste the issue + PR URLs into the Task 3 reference-doc section and the local #3102 PR description. + +--- + +### Task 5: Open the tutorials-ims PR + +**Files:** none (git/PR operation). + +- [ ] **Step 1: Push the branch and open a DEV-targeted PR** + +Per repo convention, PRs target `DEV` (not `main`). Push `worktree-fix-3102-cds-caching-upsert` and `gh pr create --base DEV`. PR body: link #3102, summarize root cause + the two-pronged fix (local patch + upstream issue/PR), link the upstream URLs, and note the patch's drop condition. + +- [ ] **Step 2: Verify the full unit suite is green in CI** + +The full `npm test` suite is hook-blocked locally; confirm the CI unit job passes on the PR before requesting review. From fe87b75c5baeaee05726eb94d5a2ff957db67731 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 14:06:01 -0500 Subject: [PATCH 08/12] fix(#3095): add 1280px container base rule for concept/topic/channel/channels page roots --- .../plans/2026-11-11-concept-page-layout.md | 236 ++++++++++++++++++ .../2026-11-11-concept-page-layout-design.md | 186 ++++++++++++++ hugo/assets/css/sap-fundamental.src.css | 19 +- test/concept-page-container.test.js | 41 +++ 4 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-11-11-concept-page-layout.md create mode 100644 docs/superpowers/specs/2026-11-11-concept-page-layout-design.md create mode 100644 test/concept-page-container.test.js diff --git a/docs/superpowers/plans/2026-11-11-concept-page-layout.md b/docs/superpowers/plans/2026-11-11-concept-page-layout.md new file mode 100644 index 000000000..a15424a04 --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-concept-page-layout.md @@ -0,0 +1,236 @@ +# Concept / topic / channel page layout fixes — 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:** Fix flush-left rendering on four CAP/Hugo page types and cap the hundreds-of-tiles relationship walls on concept hub pages (issue #3095). + +**Architecture:** Two independent changes. (A) One grouped CSS base rule (desktop + mobile media query) in the git-tracked CSS *source*, giving `.concept-page`, `.topic-detail`, `.channel-detail`, `.channels-page` the site-standard 1280px/centered/padded container. (B) An EJS change in `concept-detail.ejs` that moves KG-relationship tile overflow (past N) into a no-JS `
` collapse. + +**Tech Stack:** CSS (`hugo/assets/css/sap-fundamental.src.css`, compiled by postcss `build:css`), EJS (`srv/lib/templates/concept-detail.ejs` via `srv/lib/concept-detail-render.js`), vitest unit tests. + +**Spec:** `docs/superpowers/specs/2026-11-11-concept-page-layout-design.md` + +## Global Constraints + +- **CSS edits go ONLY in `hugo/assets/css/sap-fundamental.src.css`** (git-tracked source). NEVER edit `hugo/assets/css/sap-fundamental.css` (gitignored build output since #2896; editing it causes Hugo-fingerprint drift → blocked publish). +- **Container values copied verbatim from the sibling rule:** desktop `max-width: 1280px; margin: 0 auto; padding: 1.5rem 2rem 3rem;` (`src.css:2402-2407`); mobile `padding: 1rem;` inside the existing `@media` block (`src.css:2774-2777`). +- **No new global container class and no `main { … }` rule** — scope strictly to the four named root classes. +- **Cap mechanism is CSS-only `
`/``** — no island, no JS, overflow moved server-side (not hidden client-side). +- **Cap scope = KG-relationship sections only** (`teaches`, `requires`, `requiredBy`, `relatedTo`); leave phase-4 external sections (learningJourneys, helpDocs, blogPosts, discoveryMissions, videos, apiDocs, samples, communityEvents) uncapped — corpus data shows only these grow unbounded. +- **Cap threshold N = 24.** +- **Test runner:** `npx vitest run --project unit ` (scoped — full suite is hook-blocked locally). +- **Windows worktree:** CSS/EJS files are LF. After edits run `file `; if it flips to CRLF, normalize before commit. +- **Activation is two-step:** the CSS fix (Task 1) activates on deploy; the EJS cap (Task 2) changes body bytes → needs a concepts re-publish after deploy. Note this in the PR body. One PR. + +## Review Focus + +- **Concept with zero `requiredBy` but non-empty other sections** — the cap helper must still render normal sections untouched and emit no empty `
`. Covered in Task 2 tests. +- **`requiredBy.length` exactly === N (24)** — boundary: no `
` should appear (nothing overflows). Covered in Task 2 tests. +- **`requiredBy.length` === N+1 (25)** — boundary: exactly one item inside `
`, summary count correct. Covered in Task 2 tests. +- **Title values needing HTML-escape inside the collapsed overflow** — escaping must hold for items rendered in the `
` half, not just the visible half. Covered in Task 2 tests. +- **Concept index / topic index already-padded pages** — the new grouped rule must not double-apply to `.concepts-index`/`.topics-index` (different classes; verify by inspection that the selector list contains only the four intended classes). Covered in Task 1 review. + +--- + +## Task 1: Container base rule for the four flush-left roots (CSS) + +**Files:** +- Modify: `hugo/assets/css/sap-fundamental.src.css` (add desktop rule near `:3069` `.concept-page__*` block; add the four classes to the mobile `@media` block at `:2774-2777`) +- Test: `test/concept-page-container.test.js` (create — source-string guard, same style as `test/hugo-css-fingerprint.test.js`) + +**Interfaces:** +- Produces: CSS classes `.concept-page`, `.topic-detail`, `.channel-detail`, `.channels-page` each resolving to `max-width:1280px; margin:0 auto; padding:1.5rem 2rem 3rem` on desktop and `padding:1rem` under the mobile media query. No JS/HTML interface. + +- [ ] **Step 1: Write the failing test** + +Create `test/concept-page-container.test.js` (vitest, source-string guard — reads `sap-fundamental.src.css` as text, mirroring `hugo-css-fingerprint.test.js`): + +```js +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +const src = readFileSync(join(import.meta.dirname, '..', 'hugo/assets/css/sap-fundamental.src.css'), 'utf8'); + +describe('#3095 — concept/topic/channel container base rule', () => { + const ROOTS = ['.concept-page', '.topic-detail', '.channel-detail', '.channels-page']; + it('declares a desktop container rule with 1280px/centered/padded for all four roots', () => { + // one grouped rule block containing all four selectors + the sibling values + const rule = src.match(/\.concept-page,\s*\.topic-detail,\s*\.channel-detail,\s*\.channels-page\s*\{[^}]*\}/); + expect(rule, 'grouped base rule present').not.toBeNull(); + expect(rule[0]).toMatch(/max-width:\s*1280px/); + expect(rule[0]).toMatch(/margin:\s*0 auto/); + expect(rule[0]).toMatch(/padding:\s*1\.5rem 2rem 3rem/); + }); + it('adds the four roots to the mobile padding override', () => { + // the four classes appear alongside .group-body inside a rule setting padding:1rem + for (const r of ROOTS) { + expect(src, `${r} in a 1rem padding rule`).toMatch( + new RegExp(`${r.replace('.', '\\.')}[\\s\\S]{0,400}padding:\\s*1rem`) + ); + } + }); + it('does NOT introduce a bare `main {` width rule or touch the index classes', () => { + expect(src).not.toMatch(/\.concept-page,\s*\.topic-detail,\s*\.channel-detail,\s*\.channels-page,\s*\.concepts-index/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/concept-page-container.test.js` +Expected: FAIL (no grouped rule yet). + +- [ ] **Step 3: Add the CSS rules in `sap-fundamental.src.css`** + +Desktop: insert a grouped rule immediately before the first `.concept-page__*` rule (≈`:3069`): +```css +.concept-page, +.topic-detail, +.channel-detail, +.channels-page { + max-width: 1280px; + margin: 0 auto; + padding: 1.5rem 2rem 3rem; +} +``` +Mobile: in the existing `@media` block containing `.mission-body, .group-body { padding: 1rem; }` (`:2774-2777`), add the four selectors to that same declaration (or a sibling declaration in the same media block) so they also get `padding: 1rem`. + +- [ ] **Step 4: Run the test + verify CSS still compiles** + +Run: `npx vitest run --project unit test/concept-page-container.test.js` +Expected: PASS. +Run: `npm run build:css` +Expected: exits 0, regenerates `sap-fundamental.css` (gitignored — do not stage it). + +- [ ] **Step 5: Verify line endings, then commit** + +Run: `file hugo/assets/css/sap-fundamental.src.css test/concept-page-container.test.js` (expect "ASCII text", not "CRLF"); normalize if flipped. +```bash +git add hugo/assets/css/sap-fundamental.src.css test/concept-page-container.test.js +git commit -m "fix(#3095): add 1280px container base rule for concept/topic/channel/channels page roots" +``` + +--- + +## Task 2: Cap KG-relationship tile walls with a no-JS `
` collapse (EJS) + +**Files:** +- Modify: `srv/lib/concept-detail-render.js` (add a cap helper into the template context) +- Modify: `srv/lib/templates/concept-detail.ejs` (render the four KG sections' `
    ` via the helper) +- Test: `test/concept-detail-cap.test.js` (create) + +**Interfaces:** +- Consumes: `renderConceptDetail(concept, phase4)` from `srv/lib/concept-detail-render.js:52` → returns `{ body, contentHash }`. +- Produces: a template-context helper `renderCappedGrid(items, renderItem, { visible = 24, label })` → string of `
      ` markup where the first `visible` items are direct `
    • ` children and any overflow is wrapped in `
      Show all …
        …rest…
      `. `renderItem(item)` returns the `
    • …
    • ` for one item (reuses the existing per-section markup). When `items.length <= visible`, no `
      ` is emitted. + +- [ ] **Step 1: Write the failing tests** + +Create `test/concept-detail-cap.test.js` (vitest). Build a concept with N-boundary `requiredBy` arrays and assert on the returned `body` string: + +```js +import { describe, it, expect } from 'vitest'; +import { renderConceptDetail } from '../srv/lib/concept-detail-render.js'; +const mk = (n) => Array.from({ length: n }, (_, i) => ({ slug: `c${i}`, title: `Concept ${i}` })); +const base = { slug: 'x', name: 'X', description: '' }; + +describe('#3095 — KG relationship cap', () => { + it('no
      when requiredBy.length === 24 (boundary)', () => { + const { body } = renderConceptDetail({ ...base, requiredBy: mk(24) }, {}); + const sec = body.match(/Concepts that build on this[\s\S]*?<\/section>/)[0]; + expect(sec).not.toMatch(/
      /g) || []).length).toBe(24); + }); + it('one item inside
      when requiredBy.length === 25 (boundary)', () => { + const { body } = renderConceptDetail({ ...base, requiredBy: mk(25) }, {}); + const sec = body.match(/Concepts that build on this[\s\S]*?<\/section>/)[0]; + expect(sec).toMatch(/
      /)[0]; + expect((inside.match(/
    • /g) || []).length).toBe(1); + expect(sec).toMatch(/Show all 25/); + }); + it('235-item hub collapses overflow (24 visible, 211 in details)', () => { + const { body } = renderConceptDetail({ ...base, requiredBy: mk(235) }, {}); + const sec = body.match(/Concepts that build on this[\s\S]*?<\/section>/)[0]; + const inside = sec.match(//)[0]; + expect((inside.match(/
    • /g) || []).length).toBe(211); + }); + it('escapes titles rendered inside the collapsed overflow half', () => { + const items = mk(25); items[24].title = ''; + const { body } = renderConceptDetail({ ...base, requiredBy: items }, {}); + expect(body).not.toMatch(/'; + const { body } = renderConceptDetail({ ...base, requiredBy: items }, {}); + expect(body).not.toMatch(/