From 2e39c7e180bce04ca2ecb0fbdd516decb9c41619 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 18:57:04 -0700 Subject: [PATCH 01/14] docs(2245): design spec for installable Skill bundle endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /content/tutorials/:slug/skill — item 3 of #2245. Composes a zip (SKILL.md + verify.sh from assert blocks + provenance stamp) over the #2256/#2259 infra. Public, DB-flag-gated (SKILL_BUNDLE_ENABLED, default OFF). --- ...2026-09-11-skill-bundle-endpoint-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md diff --git a/docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md b/docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md new file mode 100644 index 000000000..783188d6a --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md @@ -0,0 +1,199 @@ +# Design: Tutorials as installable agent Skills (`GET /content/tutorials/:slug/skill`) + +**Issue:** [#2245](https://github.com/sap-tutorials/tutorials-ims/issues/2245) item 3 +**Depends on:** #2256 (signed provenance envelope), #2259 (assert blocks → parse + persist) +**Date:** 2026-09-11 +**Status:** Approved design → implementation planning + +## Goal + +Turn a published tutorial into a capability an AI agent can *install and perform*, not just +read. `GET /content/tutorials/:slug/skill` returns a zip archive that unpacks to an +installable Skill directory: a `SKILL.md` procedure, a runnable `verify.sh` generated from +the tutorial's assert blocks, and a provenance/freshness stamp. This is item 3 of the +"executable, self-verifying, self-healing tutorials" spine — it composes the outputs of +items 1 (provenance) and 2 (assert blocks) that already shipped. + +## Non-goals + +- No new persistence. The endpoint is a pure read/compose over `getTutorialSource`, + `AssertSpecs`, and `loadProvenanceInputs`. +- No actual execution of `verify.sh` server-side (that is item 4, self-healing). +- No per-code-sample separate files in v1 — code fences are carried inline in `SKILL.md` + (YAGNI; revisit if an agent consumer needs standalone sample files). +- No QA-channel (`srv-qa`) support — published-content trust surface only, mirroring the + provenance endpoint's rationale. + +## Route & registration + +- **Path:** `GET /content/tutorials/:slug/skill` — sibling of the existing + `/content/tutorials/:slug/provenance` (#2256). +- **Registration:** plain Express route inside the `cds.on('bootstrap', (app) => {...})` + block in `srv/server.js`, registered **before** the `/content/tutorials/*slug` wildcard + (`serveHandler`) so the wildcard does not swallow it — same ordering discipline as + `/provenance`. +- **Auth:** anonymous public read (no `contentAuthMiddleware`), consistent with tutorials + and `/provenance`. +- **Drift guard:** add `'GET /content/tutorials/:slug/skill'` to `ALLOWLIST_ONLY_ON_SRV` in + `scripts/check-srv-qa-route-drift.ts` with a rationale mirroring the provenance entry + (published-content-only, no srv-qa reader). + +## Feature flag + +New DB feature flag in `srv/lib/feature-flags/registry.js`, following the +`PROVENANCE_ENVELOPE_ENABLED` shape exactly: + +```js +{ + key: 'SKILL_BUNDLE_ENABLED', label: 'Installable Skill bundle endpoint', category: 'Content', + kind: 'db', imsConfigKey: 'flag.skill.bundle', + valueType: 'boolean', default: false, status: 'dev-only', + description: 'When true, serves an installable agent-Skill zip at ' + + '/content/tutorials/:slug/skill (SKILL.md procedure + verify.sh generated from assert ' + + 'blocks + provenance/freshness stamp). Public, anonymous, read-only over PUBLISHED ' + + 'tutorials. DB-driven config (ImsConfig key flag.skill.bundle); no env var. Default OFF (#2245).', + howToChange: featureFlagUpsert('SKILL_BUNDLE_ENABLED', 'flag.skill.bundle'), +} +``` + +Flag OFF → **404** (fail-closed on the feature; the endpoint should not advertise its +existence). Reading the flag reuses whatever helper `provenanceHandler` uses to read +`PROVENANCE_ENVELOPE_ENABLED` (confirm during implementation and mirror it). + +## Handler module: `srv/lib/skill-bundle.js` + +New module exporting a factory `createSkillBundleHandler(deps)` (for testability) and a +default `skillBundleHandler(req, res)` bound to production deps, mirroring the +provenance-handlers factory/default pattern. + +Injectable deps (default to the real implementations): +- `getTutorialSource(slug)` — from `content-store.js` (raw markdown). +- `loadAssertSpecs(slug)` — new small helper (below). +- `loadProvenanceInputs(slug)` + `buildEnvelope(...)` — from provenance libs. +- `isFlagEnabled('SKILL_BUNDLE_ENABLED')`. + +### Request flow + +1. **Flag check** → 404 if disabled. +2. **Canonicalize slug** (lowercase; 301 redirect on non-canonical) — reuse the existing + canonicalization helper used by `serveHandler`/`provenanceHandler`. +3. `getTutorialSource(slug)` → `{ markdown, sourceHash, contentHash }`. If `markdown` is + null/absent (legacy or unknown slug) → **404**. +4. `loadAssertSpecs(slug)` → ordered `AssertSpec[]` (may be empty). +5. `loadProvenanceInputs(slug)`; if the provenance flag is on and inputs exist, `buildEnvelope` + to obtain `{ jws, claims }`. **Fail-open**: any error or missing data → stamp degrades to + `confidence: 'unknown'`, no `jws`. The Skill bundle never fails because provenance is off. +6. Compose `SKILL.md` and `verify.sh` strings (pure functions, below). +7. Stream a zip via `archiver` (already a declared dependency, `archiver@8.0.0`): + - `Content-Type: application/zip` + - `Content-Disposition: attachment; filename="-skill.zip"` + - Entries: `/SKILL.md`, `/verify.sh` (mode `0o755`). + - On `archiver` `error` event → 500 (if headers not yet sent) + `console.error`. + +### `loadAssertSpecs(slug)` helper + +Since #2259 added no read projection, read the entity directly: + +1. `SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug })` → if none, return `[]`. +2. `SELECT.from(AssertSpecs).where({ tutorial_ID: tut.ID }).orderBy('stepNumber','assertIndex')`. +3. Map DB columns back to the logical assert shape (reverse of the #2259 remapping): + `assertType→type`, `httpMethod→method`, `httpPath→path`, `matchRegex→match`; keep + `run`, `expectExit`, `expectStatus`, `filePath`, `expectContains`, `stepNumber`, + `assertIndex`. + +Entity access via `cds.entities('com.sap.developers.ims')`. Fail-open: on error, log and +return `[]` (bundle still ships, verify.sh has no checks). + +## Composition (pure functions) + +### `buildSkillMd({ slug, title, description, markdown, asserts, stamp })` + +Returns a `SKILL.md` string: + +- **YAML frontmatter:** `name: `, `description:` (tutorial title + short description, + single-line, YAML-escaped). Frontmatter matches the agent-skill convention (name + + description are what agents index for discovery). +- **Procedure body:** the tutorial's step content derived from `markdown`. Code fences are + carried through inline as fenced blocks. Steps come from the parsed markdown structure + (H2/H3 per parser v2 — reuse the existing step-parsing helper rather than re-parsing by + hand; confirm the reusable entry point during implementation). +- **`## Verifying this Skill`** section: instructs the agent to run `bash verify.sh` + (with `BASE_URL` when http asserts are present), and states how many automated checks + are bundled. +- **`## Provenance & freshness`** section: `confidence`, `last-verified` date + (`stamp.lastScanned`), `source-commit` (`stamp.sourceCommit`), and the JWS token when + available (fenced), plus the JWKS URL for verification. + +### `buildVerifyScript(asserts)` + +Returns a bash script string: + +- Header: `#!/usr/bin/env bash` + `set -euo pipefail`, a `fail()` helper, a pass/fail + counter, and (only when any http assert exists) `BASE_URL="${BASE_URL:-http://localhost:4004}"`. +- One check block per assert, in `(stepNumber, assertIndex)` order, each echoing a labeled + check line: + - **cmd:** run `run`; capture output+exit; assert exit `== expectExit`; if `match`, + `grep -Eq -- ""` on captured output. + - **http:** `code=$(curl -s -o /tmp/... -w '%{http_code}' -X "${BASE_URL}")`; + assert `code == expectStatus`; if `match`, `grep -Eq` on the response body. + - **file:** `test -f ""`; if `expectContains`, `grep -Eq -- "" ""`. +- All interpolated values (`run`, `path`, `filePath`, `match`) are **shell-quoted** via a + single-quote-escaping helper to prevent breakage/injection from spec content. +- **Empty asserts:** script echoes `"No automated checks defined for this tutorial."` and + `exit 0`. +- Footer: print summary; `exit 1` if any check failed. + +## Error handling + +| Condition | Response | +|---|---| +| `SKILL_BUNDLE_ENABLED` off | 404 | +| Non-canonical slug | 301 → canonical `/skill` URL | +| Unknown / legacy slug (no source markdown) | 404 | +| Provenance off / no inputs / build error | 200, stamp `confidence: 'unknown'`, no JWS (fail-open) | +| AssertSpecs read error | 200, verify.sh with no checks (fail-open) | +| `archiver` stream error | 500 + logged (if headers unsent) | + +## Testing + +Follow existing patterns (`test/lib/provenance-endpoint.test.js`, `test/unit/provenance-*.test.js`, +`test/unit/assert-*.test.js`). + +- **`test/unit/skill-bundle-compose.test.js`** — pure composition: + - `buildVerifyScript`: one representative test per assert type (cmd exit + match, http + status + method, file exists, file contains), multi-assert ordering, empty → `exit 0` + notice, shell-quoting of a value containing quotes/`$`. + - `buildSkillMd`: frontmatter has `name` + `description`; provenance section reflects + `high`/`unknown` stamps; JWS included only when present. +- **`test/lib/skill-bundle-endpoint.test.js`** — handler via injected deps: + - flag off → 404; unknown slug → 404; happy path → 200, `application/zip`, + `Content-Disposition` filename; unzip (via `jszip`, already installed) and assert the + two entries exist and `verify.sh` content matches the specs; provenance-off path still + 200 with degraded stamp. +- **Route/registration:** extend `test/smoke/express-route-mutations.test.js` and the + drift-guard test (`test/unit/check-srv-qa-route-drift.test.ts`) for the new allowlist entry. + +## Files touched + +| File | Change | +|---|---| +| `srv/lib/skill-bundle.js` | **new** — handler factory + `buildSkillMd` + `buildVerifyScript` + `loadAssertSpecs` | +| `srv/server.js` | register `GET /content/tutorials/:slug/skill` before the `*slug` wildcard; import handler | +| `srv/lib/feature-flags/registry.js` | add `SKILL_BUNDLE_ENABLED` entry | +| `scripts/check-srv-qa-route-drift.ts` | add allowlist entry + rationale | +| `test/unit/skill-bundle-compose.test.js` | **new** | +| `test/lib/skill-bundle-endpoint.test.js` | **new** | +| `test/smoke/express-route-mutations.test.js` | extend for new route | +| `test/unit/check-srv-qa-route-drift.test.ts` | extend for new allowlist entry | + +## Open items to confirm during implementation + +1. The exact reusable slug-canonicalization helper and flag-read helper used by + `provenanceHandler` (mirror, don't reinvent). +2. The reusable markdown→steps parsing entry point for `buildSkillMd` (parser v2 uses H3 + steps); if none is cleanly importable server-side, fall back to emitting the raw markdown + body verbatim under the procedure section (still valid; less structured). +3. Whether `srv-qa`'s `content-store.js` cp-list is affected — `skill-bundle.js` is a new + `srv/lib` module but is **not** a transitive `./` import of `content-store.js`; it imports + *from* content-store. Confirm it is added to the `srv` module only (not srv-qa) and that + the drift guard covers the route. From 1611d6c10fb6bbcabdac006d84a54b04704102b1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:06:19 -0700 Subject: [PATCH 02/14] docs(2245): implementation plan for Skill bundle endpoint --- .../plans/2026-09-11-skill-bundle-endpoint.md | 883 ++++++++++++++++++ 1 file changed, 883 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md diff --git a/docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md b/docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md new file mode 100644 index 000000000..202980029 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-skill-bundle-endpoint.md @@ -0,0 +1,883 @@ +# Installable Skill Bundle Endpoint — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `GET /content/tutorials/:slug/skill` — a public, flag-gated endpoint that streams a zip containing an installable agent Skill (`SKILL.md` procedure + runnable `verify.sh` from assert blocks + provenance/freshness stamp). + +**Architecture:** A new pure-composition + data module `srv/lib/skill-bundle.js` reads raw tutorial markdown (`getTutorialSource`), the tutorial's `AssertSpecs`, and freshness inputs (`loadProvenanceInputs`/`deriveConfidence`/`buildEnvelope`), composes two text files, and streams them as a zip via `archiver`. It is wired as a plain Express route in `srv/server.js` registered before the `/content/tutorials/*slug` wildcard, gated by a new DB feature flag. + +**Tech Stack:** Node.js ESM, CAP (`@sap/cds`), Express, `archiver@8.0.0` (zip, already a dep), `gray-matter` (frontmatter parse, already a dep), `jose` (via existing provenance libs), Vitest. + +**Spec:** `docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md` + +## Global Constraints + +- **No new dependencies** — `archiver@8.0.0`, `gray-matter`, `js-yaml`, `jose`, `jszip` are all already declared. Adding an undeclared `srv/` runtime dep passes tests but crashes CF. +- **Never write raw SQL** — use `cds.ql` (`SELECT`/`INSERT`/`DELETE`). (The one raw-SQL exception in the codebase is BLOB reads in `content-store.js`; not needed here.) +- **Fail-open on sub-data, fail-closed on the feature** — flag OFF → 404; provenance/assert read errors → still ship the bundle with a degraded stamp / no checks. +- **Feature flags are DB config, never env** — register in `feature-flags/registry.js` (`kind:'db'`), read via `isFlagEnabled(key)` from `srv/lib/feature-flags/db-flags.js` (synchronous). +- **New DB flag MUST be in `feature-flags/registry.js`** or a registry guard test fails and it never surfaces in the admin Feature Flags UI. +- **Slugs are lowercase-canonical** — `.toLowerCase()` before any slug comparison/lookup. +- **Route ordering:** the `/skill` route MUST be registered before `app.get('/content/tutorials/*slug', serveHandler)` or the wildcard swallows it. +- **Tests:** unit/lib tests run under `vitest run --project unit` (globs `test/**/*.test.{js,ts}`); smoke under `--project smoke`. In-memory DB setup: `await cds.deploy(path.join(process.cwd(),'db','schema.cds')).to('sqlite::memory:')` (a file path — NOT `cds.model`). + +## Logical assert shape (used across tasks) + +```js +// The in-memory shape the composition consumes. It is the #2259 AssertBlock +// minus the sidecar `index` field, with DB columns mapped back to logical names. +/** @typedef {{ + * stepNumber:number, assertIndex:number, type:'cmd'|'http'|'file', + * run?:string, expectExit?:number, + * method?:string, path?:string, expectStatus?:number, + * filePath?:string, expectContains?:boolean, + * match?:string + * }} LogicalAssert */ +``` + +## Freshness stamp shape (used across tasks) + +```js +/** @typedef {{ confidence:'high'|'medium'|'low'|'unknown', + * lastVerified:string|null, sourceCommit:string|null, jws:string|null }} FreshnessStamp */ +``` + +--- + +### Task 1: Feature flag `SKILL_BUNDLE_ENABLED` + +**Files:** +- Modify: `srv/lib/feature-flags/registry.js` (add entry after the `PROVENANCE_ENVELOPE_ENABLED` entry, ~line 324) +- Test: `test/unit/feature-flags-registry.test.js` if one exists asserting per-key shape; otherwise rely on the existing registry validator test. + +**Interfaces:** +- Consumes: `featureFlagUpsert` helper already imported in `registry.js`. +- Produces: flag key `'SKILL_BUNDLE_ENABLED'`, `imsConfigKey:'flag.skill.bundle'`, read via `isFlagEnabled('SKILL_BUNDLE_ENABLED')`. + +- [ ] **Step 1: Add the registry entry** + +In `srv/lib/feature-flags/registry.js`, immediately after the `PROVENANCE_ENVELOPE_ENABLED` object: + +```js +{ + key: 'SKILL_BUNDLE_ENABLED', label: 'Installable Skill bundle endpoint', category: 'Content', + kind: 'db', imsConfigKey: 'flag.skill.bundle', + valueType: 'boolean', default: false, status: 'dev-only', + description: 'When true, serves an installable agent-Skill zip at ' + + '/content/tutorials/:slug/skill (SKILL.md procedure + verify.sh generated from assert ' + + 'blocks + provenance/freshness stamp). Public, anonymous, read-only over PUBLISHED ' + + 'tutorials. DB-driven config (ImsConfig key flag.skill.bundle); no env var. Default OFF (#2245).', + howToChange: featureFlagUpsert('SKILL_BUNDLE_ENABLED', 'flag.skill.bundle'), +}, +``` + +- [ ] **Step 2: Run the registry guard test to verify it still passes** + +Run: `npx vitest run --project unit test/unit/feature-flags-registry.test.js` (if the file does not exist, run the whole unit tier's registry-related test: `npx vitest run --project unit -t "registry"`) +Expected: PASS (the new entry conforms to the schema; no duplicate key). + +- [ ] **Step 3: Commit** + +```bash +git add srv/lib/feature-flags/registry.js +git commit -m "feat(2245): register SKILL_BUNDLE_ENABLED feature flag" +``` + +--- + +### Task 2: `buildVerifyScript` + shell-quoting (pure) + +**Files:** +- Create: `srv/lib/skill-bundle.js` (start the module with these two pure exports) +- Test: `test/unit/skill-bundle-compose.test.js` + +**Interfaces:** +- Produces: `export function shquote(s: string): string` and `export function buildVerifyScript(asserts: LogicalAssert[]): string`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/skill-bundle-compose.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { buildVerifyScript, shquote } from '../../srv/lib/skill-bundle.js'; + +describe('shquote', () => { + it('wraps in single quotes and escapes embedded single quotes', () => { + expect(shquote(`a'b`)).toBe(`'a'\\''b'`); + expect(shquote('cds compile')).toBe(`'cds compile'`); + }); +}); + +describe('buildVerifyScript', () => { + it('emits a bash header with strict mode', () => { + const s = buildVerifyScript([]); + expect(s.startsWith('#!/usr/bin/env bash\n')).toBe(true); + expect(s).toContain('set -euo pipefail'); + }); + + it('empty asserts → notice + exit 0', () => { + const s = buildVerifyScript([]); + expect(s).toContain('No automated checks defined'); + expect(s.trimEnd().endsWith('exit 0')).toBe(true); + }); + + it('cmd assert runs the command and checks exit code + optional match', () => { + const s = buildVerifyScript([{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'cds compile', expectExit: 0, match: 'ok' }]); + expect(s).toContain(`'cds compile'`); + expect(s).toContain('-eq 0'); + expect(s).toContain('grep -Eq'); + }); + + it('http assert curls BASE_URL+path with method and checks status', () => { + const s = buildVerifyScript([{ stepNumber: 2, assertIndex: 0, type: 'http', method: 'GET', path: '/foo', expectStatus: 200 }]); + expect(s).toContain('BASE_URL="${BASE_URL:-http://localhost:4004}"'); + expect(s).toContain('-X GET'); + expect(s).toContain('${BASE_URL}/foo'.replace('/foo', "'/foo'").length ? '/foo' : '/foo'); // path is shell-quoted + expect(s).toContain('200'); + }); + + it('file exists vs contains', () => { + const exists = buildVerifyScript([{ stepNumber: 3, assertIndex: 0, type: 'file', filePath: 'a.cds', expectContains: false }]); + expect(exists).toContain('test -f'); + expect(exists).not.toContain('grep -Eq'); + const contains = buildVerifyScript([{ stepNumber: 3, assertIndex: 0, type: 'file', filePath: 'a.cds', expectContains: true, match: 'service' }]); + expect(contains).toContain('grep -Eq'); + }); + + it('preserves (stepNumber, assertIndex) order and fails overall when any check fails', () => { + const s = buildVerifyScript([ + { stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'a', expectExit: 0 }, + { stepNumber: 1, assertIndex: 1, type: 'cmd', run: 'b', expectExit: 0 }, + ]); + expect(s.indexOf("'a'")).toBeLessThan(s.indexOf("'b'")); + expect(s).toContain('exit 1'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js` +Expected: FAIL — cannot resolve `../../srv/lib/skill-bundle.js` (module not created yet). + +- [ ] **Step 3: Implement the two functions** + +Create `srv/lib/skill-bundle.js`: + +```js +// srv/lib/skill-bundle.js +// GET /content/tutorials/:slug/skill — composes an installable agent Skill +// (SKILL.md + verify.sh) as a zip. Item 3 of #2245. Pure composition first, +// data + handler wiring below. + +const DEFAULT_BASE_URL = 'http://localhost:4004'; + +/** POSIX single-quote escape: a'b -> 'a'\''b' */ +export function shquote(s) { + return `'${String(s).replace(/'/g, `'\\''`)}'`; +} + +/** Build a runnable bash verifier from the tutorial's assert specs. */ +export function buildVerifyScript(asserts) { + const hasHttp = asserts.some((a) => a.type === 'http'); + const L = []; + L.push('#!/usr/bin/env bash'); + L.push('# Generated by SAP Tutorials — verifies this Skill against SAP\'s official steps.'); + L.push('set -euo pipefail'); + L.push(''); + if (hasHttp) L.push('BASE_URL="${BASE_URL:-' + DEFAULT_BASE_URL + '}"'); + L.push('fails=0'); + L.push('check() { if [ "$1" -eq 0 ]; then echo " PASS: $2"; else echo " FAIL: $2"; fails=$((fails+1)); fi; }'); + L.push(''); + + if (asserts.length === 0) { + L.push('echo "No automated checks defined for this tutorial."'); + L.push('exit 0'); + return L.join('\n') + '\n'; + } + + for (const a of asserts) { + const label = shquote(`step ${a.stepNumber} assert ${a.assertIndex} (${a.type})`); + L.push(`echo "Running check for ${a.type} @ step ${a.stepNumber}.${a.assertIndex}"`); + if (a.type === 'cmd') { + L.push('out=$(' + a.run + ' 2>&1) && rc=$? || rc=$?'); + L.push(`if [ "$rc" -eq ${Number(a.expectExit)} ]; then ok=0; else ok=1; fi`); + if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! printf '%s' "$out" | grep -Eq -- ${shquote(a.match)}; then ok=1; fi`); + L.push(`check "$ok" ${label}`); + } else if (a.type === 'http') { + L.push(`body=$(mktemp)`); + L.push(`code=$(curl -s -o "$body" -w '%{http_code}' -X ${a.method} "${'${BASE_URL}'}"${shquote(a.path)} || echo 000)`); + L.push(`if [ "$code" = ${shquote(String(a.expectStatus))} ]; then ok=0; else ok=1; fi`); + if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! grep -Eq -- ${shquote(a.match)} "$body"; then ok=1; fi`); + L.push(`rm -f "$body"`); + L.push(`check "$ok" ${label}`); + } else if (a.type === 'file') { + if (a.expectContains) { + L.push(`if [ -f ${shquote(a.filePath)} ] && grep -Eq -- ${shquote(a.match || '')} ${shquote(a.filePath)}; then ok=0; else ok=1; fi`); + } else { + L.push(`if test -f ${shquote(a.filePath)}; then ok=0; else ok=1; fi`); + } + L.push(`check "$ok" ${label}`); + } + L.push(''); + } + + L.push('if [ "$fails" -gt 0 ]; then echo "$fails check(s) failed."; exit 1; fi'); + L.push('echo "All checks passed."'); + return L.join('\n') + '\n'; +} +``` + +> Note: fix the deliberately-awkward `path is shell-quoted` assertion in Step 1 to a plain +> `expect(s).toContain(shquote('/foo'))` once you import `shquote` — keep the test readable. + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js` +Expected: PASS (all `buildVerifyScript`/`shquote` cases). + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/skill-bundle.js test/unit/skill-bundle-compose.test.js +git commit -m "feat(2245): generate verify.sh from assert specs" +``` + +--- + +### Task 3: `buildSkillMd` (pure) + +**Files:** +- Modify: `srv/lib/skill-bundle.js` (add export) +- Test: `test/unit/skill-bundle-compose.test.js` (add a `describe`) + +**Interfaces:** +- Consumes: `gray-matter` (default import), the `FreshnessStamp` typedef. +- Produces: `export function buildSkillMd({ slug, source, asserts, stamp }): string` — `source` is the raw tutorial markdown (with frontmatter), `asserts: LogicalAssert[]`, `stamp: FreshnessStamp`. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/unit/skill-bundle-compose.test.js`: + +```js +import { buildSkillMd } from '../../srv/lib/skill-bundle.js'; + +const SRC = `---\ntitle: Create a CAP Service\ndescription: Build and run a CAP service.\n---\n\n## Step 1\nDo the thing.\n`; + +describe('buildSkillMd', () => { + it('emits YAML frontmatter with name (slug) and description (from source)', () => { + const md = buildSkillMd({ slug: 'create-cap-service', source: SRC, asserts: [], stamp: { confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc123', jws: null } }); + expect(md).toMatch(/^---\n/); + expect(md).toContain('name: create-cap-service'); + expect(md).toContain('Create a CAP Service'); // description carried from source title/description + }); + + it('includes the procedure body (source minus frontmatter)', () => { + const md = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null } }); + expect(md).toContain('Do the thing.'); + expect(md).not.toContain('title: Create a CAP Service'); // frontmatter not duplicated into body + }); + + it('provenance section reflects the stamp and states check count', () => { + const md = buildSkillMd({ slug: 's', source: SRC, asserts: [{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'x', expectExit: 0 }], stamp: { confidence: 'medium', lastVerified: '2026-08-01', sourceCommit: 'deadbeef', jws: null } }); + expect(md).toContain('confidence: medium'); + expect(md).toContain('2026-08-01'); + expect(md).toContain('deadbeef'); + expect(md).toContain('1'); // one bundled check + }); + + it('includes the JWS fenced block only when present', () => { + const withJws = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc', jws: 'eyJ.sig' } }); + expect(withJws).toContain('eyJ.sig'); + const without = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null } }); + expect(without).not.toContain('```jws'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js -t buildSkillMd` +Expected: FAIL — `buildSkillMd` is not exported. + +- [ ] **Step 3: Implement `buildSkillMd`** + +Add to `srv/lib/skill-bundle.js` (top: `import matter from 'gray-matter';`): + +```js +export function buildSkillMd({ slug, source, asserts, stamp }) { + let title = slug; + let description = ''; + let body = String(source || ''); + try { + const parsed = matter(String(source || '')); + title = parsed.data.title || slug; + description = parsed.data.description || ''; + body = parsed.content.trim(); + } catch { + body = String(source || '').trim(); + } + // single-line, quote-safe description for YAML + const desc = `${title}${description ? ' — ' + description : ''}`.replace(/\s+/g, ' ').replace(/"/g, "'").trim(); + + const fm = ['---', `name: ${slug}`, `description: "${desc}"`, '---', ''].join('\n'); + + const n = asserts.length; + const verifyLine = asserts.some((a) => a.type === 'http') + ? 'Run `BASE_URL= bash verify.sh` to check your work.' + : 'Run `bash verify.sh` to check your work.'; + + const provenance = [ + '## Provenance & freshness', + '', + `- confidence: ${stamp.confidence}`, + `- last-verified: ${stamp.lastVerified || 'unknown'}`, + `- source-commit: ${stamp.sourceCommit || 'unknown'}`, + '- source: sap-tutorials/Tutorials', + ]; + if (stamp.jws) { + provenance.push('', 'Signed attestation (verify against the JWKS at `/.well-known/tutorial-provenance/jwks.json`):', '', '```jws', stamp.jws, '```'); + } + + const verify = [ + '## Verifying this Skill', + '', + n === 0 + ? 'No automated checks are bundled with this tutorial. Follow the procedure above.' + : `${n} automated check(s) are bundled in \`verify.sh\`. ${verifyLine}`, + ]; + + return [fm, `# ${title}`, '', body, '', verify.join('\n'), '', provenance.join('\n'), ''].join('\n'); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js` +Expected: PASS (both `buildVerifyScript` and `buildSkillMd` groups). + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/skill-bundle.js test/unit/skill-bundle-compose.test.js +git commit -m "feat(2245): compose SKILL.md from tutorial source + stamp" +``` + +--- + +### Task 4: `loadAssertSpecs(slug)` data helper + +**Files:** +- Modify: `srv/lib/skill-bundle.js` (add export) +- Test: `test/unit/skill-bundle-data.test.js` + +**Interfaces:** +- Consumes: `cds.entities('com.sap.developers.ims')` → `Tutorials`, `AssertSpecs`. +- Produces: `export async function loadAssertSpecs(slug: string): Promise` — ordered by `(stepNumber, assertIndex)`, DB columns remapped to logical names; `[]` on unknown slug or error. + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/skill-bundle-data.test.js`: + +```js +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import path from 'node:path'; +import cds from '@sap/cds'; +import { loadAssertSpecs } from '../../srv/lib/skill-bundle.js'; + +beforeAll(async () => { + await cds.deploy(path.join(process.cwd(), 'db', 'schema.cds')).to('sqlite::memory:'); +}); + +beforeEach(async () => { + const { AssertSpecs, Tutorials } = cds.entities('com.sap.developers.ims'); + await DELETE.from(AssertSpecs); + await DELETE.from(Tutorials); + await INSERT.into(Tutorials).entries([{ ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', slug: 'tutorial-alpha', title: 'Alpha', status: 'ACTIVE' }]); + await INSERT.into(AssertSpecs).entries([ + { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 2, assertIndex: 0, assertType: 'http', httpMethod: 'GET', httpPath: '/foo', expectStatus: 200 }, + { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 1, assertIndex: 1, assertType: 'cmd', run: 'b', expectExit: 0 }, + { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 1, assertIndex: 0, assertType: 'cmd', run: 'a', expectExit: 0, matchRegex: 'ok' }, + ]); +}); + +describe('loadAssertSpecs', () => { + it('returns [] for an unknown slug', async () => { + expect(await loadAssertSpecs('nope')).toEqual([]); + }); + + it('orders by (stepNumber, assertIndex) and remaps columns to logical names', async () => { + const specs = await loadAssertSpecs('tutorial-alpha'); + expect(specs.map((s) => `${s.stepNumber}.${s.assertIndex}`)).toEqual(['1.0', '1.1', '2.0']); + expect(specs[0]).toMatchObject({ type: 'cmd', run: 'a', expectExit: 0, match: 'ok' }); + expect(specs[2]).toMatchObject({ type: 'http', method: 'GET', path: '/foo', expectStatus: 200 }); + }); + + it('lowercases the slug before lookup', async () => { + const specs = await loadAssertSpecs('TUTORIAL-ALPHA'); + expect(specs).toHaveLength(3); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js` +Expected: FAIL — `loadAssertSpecs` not exported. + +- [ ] **Step 3: Implement `loadAssertSpecs`** + +Add to `srv/lib/skill-bundle.js` (top: `import cds from '@sap/cds';`): + +```js +export async function loadAssertSpecs(slug) { + try { + const lc = String(slug || '').toLowerCase(); + const { Tutorials, AssertSpecs } = cds.entities('com.sap.developers.ims'); + const tut = await SELECT.one.from(Tutorials).columns('ID').where({ slug: lc }); + if (!tut) return []; + const rows = await SELECT.from(AssertSpecs).where({ tutorial_ID: tut.ID }).orderBy('stepNumber', 'assertIndex'); + return rows.map((r) => ({ + stepNumber: r.stepNumber, + assertIndex: r.assertIndex, + type: r.assertType, + run: r.run ?? undefined, + expectExit: r.expectExit ?? undefined, + method: r.httpMethod ?? undefined, + path: r.httpPath ?? undefined, + expectStatus: r.expectStatus ?? undefined, + filePath: r.filePath ?? undefined, + expectContains: typeof r.expectContains === 'boolean' ? r.expectContains : undefined, + match: r.matchRegex ?? undefined, + })); + } catch (e) { + console.warn('[skill-bundle] loadAssertSpecs fail-open:', e.message); + return []; + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/skill-bundle.js test/unit/skill-bundle-data.test.js +git commit -m "feat(2245): loadAssertSpecs reads + remaps AssertSpecs by slug" +``` + +--- + +### Task 5: `buildFreshnessStamp(slug, opts)` data helper + +**Files:** +- Modify: `srv/lib/skill-bundle.js` (add export) +- Test: `test/lib/skill-bundle-endpoint.test.js` (created in Task 6 — but add a focused stamp `describe` here via injected deps; if executing strictly in order, put this test in `test/unit/skill-bundle-data.test.js` instead) + +**Interfaces:** +- Consumes: `loadProvenanceInputs` from `./provenance-data.js`, `deriveConfidence` from `./provenance-freshness.js`, `buildEnvelope` from `./provenance-envelope.js` — all injectable for testing. +- Produces: `export function makeFreshnessStampLoader({ loadProvenanceInputs, deriveConfidence, buildEnvelope })` returning `async (slug, { provenanceEnabled }) => FreshnessStamp`; plus a default `export async function buildFreshnessStamp(slug, opts)` bound to the real deps. + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/skill-bundle-data.test.js`: + +```js +import { makeFreshnessStampLoader } from '../../srv/lib/skill-bundle.js'; + +describe('freshness stamp', () => { + const inputs = { contentHash: 'h', sourceCommit: 'sha1', builtAt: '2026-09-01', report: { status: 'DONE', runAt: '2026-09-01', openHighCount: 0 } }; + + it('derives confidence + commit without a signing key, no jws when provenance disabled', async () => { + const load = makeFreshnessStampLoader({ + loadProvenanceInputs: async () => inputs, + deriveConfidence: () => 'high', + buildEnvelope: async () => ({ jws: 'should.not.appear' }), + }); + const stamp = await load('s', { provenanceEnabled: false }); + expect(stamp).toMatchObject({ confidence: 'high', sourceCommit: 'sha1', lastVerified: '2026-09-01', jws: null }); + }); + + it('adds jws when provenance enabled and envelope builds', async () => { + const load = makeFreshnessStampLoader({ + loadProvenanceInputs: async () => inputs, + deriveConfidence: () => 'high', + buildEnvelope: async () => ({ jws: 'eyJ.sig' }), + }); + const stamp = await load('s', { provenanceEnabled: true }); + expect(stamp.jws).toBe('eyJ.sig'); + }); + + it('fail-open to unknown when inputs are null', async () => { + const load = makeFreshnessStampLoader({ + loadProvenanceInputs: async () => null, + deriveConfidence: () => 'unknown', + buildEnvelope: async () => null, + }); + const stamp = await load('s', { provenanceEnabled: true }); + expect(stamp).toEqual({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js -t "freshness stamp"` +Expected: FAIL — `makeFreshnessStampLoader` not exported. + +- [ ] **Step 3: Implement** + +Add to `srv/lib/skill-bundle.js`: + +```js +import { loadProvenanceInputs as _loadProvenanceInputs } from './provenance-data.js'; +import { deriveConfidence as _deriveConfidence } from './provenance-freshness.js'; +import { buildEnvelope as _buildEnvelope } from './provenance-envelope.js'; + +export function makeFreshnessStampLoader({ loadProvenanceInputs, deriveConfidence, buildEnvelope }) { + return async function loadStamp(slug, { provenanceEnabled }) { + try { + const inputs = await loadProvenanceInputs(String(slug || '').toLowerCase()); + if (!inputs) return { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }; + const confidence = deriveConfidence({ report: inputs.report, now: Date.now() }); + const lastVerified = inputs.report?.runAt || inputs.builtAt || null; + let jws = null; + if (provenanceEnabled) { + try { + const env = await buildEnvelope({ slug: String(slug).toLowerCase(), ...inputs }); + jws = env?.jws || null; + } catch { jws = null; } + } + return { confidence, lastVerified, sourceCommit: inputs.sourceCommit || null, jws }; + } catch (e) { + console.warn('[skill-bundle] freshness stamp fail-open:', e.message); + return { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }; + } + }; +} + +export const buildFreshnessStamp = (slug, opts) => + makeFreshnessStampLoader({ + loadProvenanceInputs: _loadProvenanceInputs, + deriveConfidence: _deriveConfidence, + buildEnvelope: _buildEnvelope, + })(slug, opts); +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run --project unit test/unit/skill-bundle-data.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/skill-bundle.js test/unit/skill-bundle-data.test.js +git commit -m "feat(2245): freshness stamp (confidence + commit + optional jws)" +``` + +--- + +### Task 6: `createSkillBundleHandler` + zip streaming + +**Files:** +- Modify: `srv/lib/skill-bundle.js` (add handler factory + default export) +- Test: `test/lib/skill-bundle-endpoint.test.js` + +**Interfaces:** +- Consumes: `archiver` (`import archiver from 'archiver'`), `isFlagEnabled` from `./feature-flags/db-flags.js`, `getTutorialSource` from `./content-store.js`, plus `loadAssertSpecs`/`buildFreshnessStamp`/`buildSkillMd`/`buildVerifyScript` from this module — all injectable. +- Produces: `export function createSkillBundleHandler(deps = {}): (req, res) => Promise` and `export const skillBundleHandler = createSkillBundleHandler()`. +- Response: `application/zip`, `Content-Disposition: attachment; filename="-skill.zip"`, entries `/SKILL.md` and `/verify.sh` (mode `0o755`). + +- [ ] **Step 1: Write the failing tests** + +Create `test/lib/skill-bundle-endpoint.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { Writable } from 'node:stream'; +import JSZip from 'jszip'; +import { createSkillBundleHandler } from '../../srv/lib/skill-bundle.js'; + +// Collect the streamed zip into a buffer via a fake res that is a Writable. +function fakeRes() { + const chunks = []; + const res = new Writable({ write(c, _e, cb) { chunks.push(Buffer.from(c)); cb(); } }); + res.statusCode = 200; + res.headers = {}; + res.setHeader = (k, v) => { res.headers[k.toLowerCase()] = v; }; + res.status = (c) => { res.statusCode = c; return res; }; + res.json = (b) => { res.jsonBody = b; res.end(); return res; }; + res.buffer = () => Buffer.concat(chunks); + return res; +} +const req = (slug) => ({ params: { slug } }); + +const baseDeps = { + isFlagEnabled: () => true, + getTutorialSource: async () => ({ markdown: `---\ntitle: T\ndescription: D\n---\n\n## Step 1\nBody.\n` }), + loadAssertSpecs: async () => [{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'x', expectExit: 0 }], + buildFreshnessStamp: async () => ({ confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc', jws: null }), + provenanceFlagKey: 'PROVENANCE_ENVELOPE_ENABLED', +}; + +describe('skill bundle handler', () => { + it('404 when feature flag is off', async () => { + const res = fakeRes(); + await createSkillBundleHandler({ ...baseDeps, isFlagEnabled: () => false })(req('t'), res); + expect(res.statusCode).toBe(404); + }); + + it('404 when the slug has no source markdown', async () => { + const res = fakeRes(); + await createSkillBundleHandler({ ...baseDeps, getTutorialSource: async () => ({ markdown: null }) })(req('nope'), res); + expect(res.statusCode).toBe(404); + }); + + it('200 streams a zip with SKILL.md and verify.sh', async () => { + const res = fakeRes(); + await createSkillBundleHandler(baseDeps)(req('my-tutorial'), res); + await new Promise((r) => res.on('finish', r)); + expect(res.headers['content-type']).toContain('application/zip'); + expect(res.headers['content-disposition']).toContain('my-tutorial-skill.zip'); + const zip = await JSZip.loadAsync(res.buffer()); + expect(zip.file('my-tutorial/SKILL.md')).toBeTruthy(); + const verify = await zip.file('my-tutorial/verify.sh').async('string'); + expect(verify).toContain('#!/usr/bin/env bash'); + expect(verify).toContain("'x'"); + const skill = await zip.file('my-tutorial/SKILL.md').async('string'); + expect(skill).toContain('name: my-tutorial'); + expect(skill).toContain('confidence: high'); + }); + + it('still ships (degraded) when provenance/asserts are empty', async () => { + const res = fakeRes(); + await createSkillBundleHandler({ ...baseDeps, loadAssertSpecs: async () => [], buildFreshnessStamp: async () => ({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }) })(req('t'), res); + await new Promise((r) => res.on('finish', r)); + const zip = await JSZip.loadAsync(res.buffer()); + const verify = await zip.file('t/verify.sh').async('string'); + expect(verify).toContain('No automated checks defined'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run --project unit test/lib/skill-bundle-endpoint.test.js` +Expected: FAIL — `createSkillBundleHandler` not exported. + +- [ ] **Step 3: Implement the handler** + +Add to `srv/lib/skill-bundle.js` (top: `import archiver from 'archiver';`, `import { isFlagEnabled as _isFlagEnabled } from './feature-flags/db-flags.js';`, `import { getTutorialSource as _getTutorialSource } from './content-store.js';`): + +```js +const VALID_SLUG = /^[a-z0-9]+(?:[-/][a-z0-9]+)*$/; + +export function createSkillBundleHandler(deps = {}) { + const { + isFlagEnabled = _isFlagEnabled, + getTutorialSource = _getTutorialSource, + loadAssertSpecs: _load = loadAssertSpecs, + buildFreshnessStamp: _stamp = buildFreshnessStamp, + provenanceFlagKey = 'PROVENANCE_ENVELOPE_ENABLED', + } = deps; + + return async function handler(req, res) { + if (!isFlagEnabled('SKILL_BUNDLE_ENABLED')) return res.status(404).end(); + + const raw = Array.isArray(req.params?.slug) ? req.params.slug.join('/') : req.params?.slug; + const slug = String(raw || '').replace(/\/$/, '').toLowerCase(); + if (!slug || !VALID_SLUG.test(slug)) return res.status(404).json({ error: 'not_found' }); + + const src = await getTutorialSource(slug); + if (!src || !src.markdown) return res.status(404).json({ error: 'not_found' }); + + const [asserts, stamp] = await Promise.all([ + _load(slug), + _stamp(slug, { provenanceEnabled: isFlagEnabled(provenanceFlagKey) }), + ]); + + const skillMd = buildSkillMd({ slug, source: src.markdown, asserts, stamp }); + const verifySh = buildVerifyScript(asserts); + + res.status(200); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug.replace(/\//g, '-')}-skill.zip"`); + res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600'); + + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err) => { + console.error('[skill-bundle] archive error:', err.message); + if (!res.headersSent) res.status(500).end(); else res.destroy(err); + }); + archive.pipe(res); + const dir = slug.replace(/\//g, '-'); + archive.append(skillMd, { name: `${dir}/SKILL.md` }); + archive.append(verifySh, { name: `${dir}/verify.sh`, mode: 0o755 }); + await archive.finalize(); + }; +} + +export const skillBundleHandler = createSkillBundleHandler(); +``` + +> Note: `getTutorialSource` lowercases internally too, but we canonicalize here so the zip +> dir name and `Content-Disposition` are stable. `VALID_SLUG` mirrors `content-store.js`. + +- [ ] **Step 4: Run to verify pass** + +Run: `npx vitest run --project unit test/lib/skill-bundle-endpoint.test.js` +Expected: PASS (4 cases). + +- [ ] **Step 5: Run the full compose/data/endpoint set + lint** + +Run: `npx vitest run --project unit test/unit/skill-bundle-compose.test.js test/unit/skill-bundle-data.test.js test/lib/skill-bundle-endpoint.test.js` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/skill-bundle.js test/lib/skill-bundle-endpoint.test.js +git commit -m "feat(2245): skill bundle handler streams SKILL.md + verify.sh zip" +``` + +--- + +### Task 7: Wire the route in `srv/server.js` + +**Files:** +- Modify: `srv/server.js` (import + route registration inside the `cds.on('bootstrap', (app) => {...})` block) +- Test: `test/smoke/express-route-mutations.test.js` (extend the registered-route assertion set) + +**Interfaces:** +- Consumes: `skillBundleHandler` from `./lib/skill-bundle.js`. +- Produces: registered route `GET /content/tutorials/:slug/skill`, positioned before `app.get('/content/tutorials/*slug', serveHandler)`. + +- [ ] **Step 1: Locate the provenance route registration** + +Run: `grep -n "provenanceHandler\|/content/tutorials/\*slug\|/content/tutorials/:slug/provenance" srv/server.js` +Expected: shows the `app.get('/content/tutorials/:slug/provenance', provenanceHandler)` line registered before the `*slug` wildcard. + +- [ ] **Step 2: Add the import** + +Near the other `srv/lib` imports at the top of `srv/server.js`: + +```js +import { skillBundleHandler } from './lib/skill-bundle.js'; +``` + +- [ ] **Step 3: Register the route (immediately after the `/provenance` route)** + +```js +app.get('/content/tutorials/:slug/skill', skillBundleHandler); +``` + +- [ ] **Step 4: Extend the route-mutation smoke test** + +In `test/smoke/express-route-mutations.test.js`, add `'GET /content/tutorials/:slug/skill'` to the expected-registered-routes set (match how `/provenance` is asserted there). + +- [ ] **Step 5: Run the route smoke test** + +Run: `npx vitest run --project smoke test/smoke/express-route-mutations.test.js` +Expected: PASS (new route present, registered before the wildcard). + +- [ ] **Step 6: Commit** + +```bash +git add srv/server.js test/smoke/express-route-mutations.test.js +git commit -m "feat(2245): register GET /content/tutorials/:slug/skill route" +``` + +--- + +### Task 8: Route-drift guard allowlist entry + +**Files:** +- Modify: `scripts/check-srv-qa-route-drift.ts` (`ALLOWLIST_ONLY_ON_SRV`, ~line 73) +- Test: `test/unit/check-srv-qa-route-drift.test.ts` (verify guard passes; extend if it enumerates allowlist keys) + +**Interfaces:** +- Consumes: nothing new. +- Produces: allowlist key `'GET /content/tutorials/:slug/skill'`. + +- [ ] **Step 1: Add the allowlist entry** + +In `scripts/check-srv-qa-route-drift.ts`, inside `ALLOWLIST_ONLY_ON_SRV`, after the `/provenance` entry: + +```ts + 'GET /content/tutorials/:slug/skill': + 'Installable Skill bundle (#2245) — an anonymous, public, read-only prod content ' + + 'surface that streams a zip (SKILL.md + verify.sh from assert blocks + provenance stamp) ' + + 'over PUBLISHED tutorials. Feature-flagged (SKILL_BUNDLE_ENABLED, DB config, default OFF, ' + + 'DEV-first) and fail-open. Not a QA-channel surface: srv-qa serves tutorials behind ' + + 'requireAuthorScope (author-draft preview) and the Skill bundle is meaningful only for ' + + 'published content. Mirror of the /provenance allowlist rationale.', +``` + +- [ ] **Step 2: Run the drift-guard test** + +Run: `npx vitest run --project unit test/unit/check-srv-qa-route-drift.test.ts` +Expected: PASS (the new srv-only route is allowlisted; guard reports no drift). + +- [ ] **Step 3: Commit** + +```bash +git add scripts/check-srv-qa-route-drift.ts +git commit -m "feat(2245): allowlist /skill as srv-only in route-drift guard" +``` + +--- + +### Task 9: Full-suite verification + PR + +**Files:** none (verification + PR). + +- [ ] **Step 1: Run the whole unit tier** + +Run: `npm test` +Expected: PASS (no regressions; new skill-bundle tests included). + +- [ ] **Step 2: Run the route smoke test once more** + +Run: `npx vitest run --project smoke test/smoke/express-route-mutations.test.js` +Expected: PASS. + +- [ ] **Step 3: Confirm no new dependency crept in** + +Run: `git diff origin/DEV -- package.json package-lock.json` +Expected: EMPTY (archiver/gray-matter/jszip were already declared). + +- [ ] **Step 4: Push and open a PR targeting DEV** + +```bash +git push -u origin feat/skill-bundle-2245 +gh pr create --repo sap-tutorials/tutorials-ims --base DEV --head feat/skill-bundle-2245 \ + --title "feat(2245): installable Skill bundle endpoint (item 3)" \ + --body "Implements item 3 of #2245: GET /content/tutorials/:slug/skill streams an installable agent Skill (SKILL.md + verify.sh from assert blocks + provenance stamp) as a zip. Public, gated by SKILL_BUNDLE_ENABLED (DB flag, default OFF). Builds on #2256 (provenance) and #2259 (assert blocks). Spec: docs/superpowers/specs/2026-09-11-skill-bundle-endpoint-design.md" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Route + registration before wildcard → Task 7. ✅ +- Feature flag `SKILL_BUNDLE_ENABLED`, 404 fail-closed → Task 1 + Task 6 Step 3. ✅ +- `getTutorialSource` → 404 on missing → Task 6. ✅ +- `loadAssertSpecs` direct entity read + column remap → Task 4. ✅ +- Freshness stamp (confidence/date/commit, JWS only when provenance on) → Task 5. ✅ +- `buildSkillMd` (frontmatter name/description, procedure body, provenance section) → Task 3. ✅ +- `buildVerifyScript` (cmd/http/file, empty→exit 0, shell-quoting, ordering) → Task 2. ✅ +- Zip via archiver, `application/zip` + `Content-Disposition` → Task 6. ✅ +- Error-handling table (flag/slug/fail-open/stream error) → Tasks 6 (handler) + 2/4/5 (fail-open). ✅ +- Drift-guard allowlist → Task 8. ✅ +- Testing (compose unit, endpoint via jszip, route smoke, drift guard) → Tasks 2/3/4/5/6/7/8. ✅ +- "No new dependency" verification → Task 9 Step 3. ✅ + +**Placeholder scan:** No TBD/TODO. The one awkward assertion in Task 2 Step 1 is flagged with an explicit fix instruction (use `shquote('/foo')`). No "similar to Task N" — code is repeated where read out of order. + +**Type consistency:** `LogicalAssert` fields (`type`, `method`, `path`, `match`, `stepNumber`, `assertIndex`, `expectExit`, `expectStatus`, `filePath`, `expectContains`, `run`) are consistent across `loadAssertSpecs` (Task 4), `buildVerifyScript` (Task 2), `buildSkillMd` (Task 3), and endpoint deps (Task 6). `FreshnessStamp` (`confidence`, `lastVerified`, `sourceCommit`, `jws`) consistent across Tasks 3, 5, 6. Handler dep names (`isFlagEnabled`, `getTutorialSource`, `loadAssertSpecs`, `buildFreshnessStamp`, `provenanceFlagKey`) consistent between the factory (Task 6 Step 3) and its tests (Task 6 Step 1). From 151d78bd99f0bf44013a0bcbf9f7f18f98c6bd79 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:20:53 -0700 Subject: [PATCH 03/14] feat(2245): register SKILL_BUNDLE_ENABLED feature flag --- srv/lib/feature-flags/registry.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index 0b8bf8aa9..73db11aa4 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -322,6 +322,16 @@ export const FEATURE_FLAGS = [ description: 'When true, serves the signed provenance JWS at /content/tutorials/:slug/provenance, publishes the JWKS at /.well-known/tutorial-provenance/jwks.json, and emits advisory X-Freshness-Confidence / X-Content-Provenance headers. DB-driven config (ImsConfig key flag.provenance.envelope); no env var. Default OFF.', howToChange: featureFlagUpsert('PROVENANCE_ENVELOPE_ENABLED', 'flag.provenance.envelope'), }, + { + key: 'SKILL_BUNDLE_ENABLED', label: 'Installable Skill bundle endpoint', category: 'Content', + kind: 'db', imsConfigKey: 'flag.skill.bundle', + valueType: 'boolean', default: false, status: 'dev-only', + description: 'When true, serves an installable agent-Skill zip at ' + + '/content/tutorials/:slug/skill (SKILL.md procedure + verify.sh generated from assert ' + + 'blocks + provenance/freshness stamp). Public, anonymous, read-only over PUBLISHED ' + + 'tutorials. DB-driven config (ImsConfig key flag.skill.bundle); no env var. Default OFF (#2245).', + howToChange: featureFlagUpsert('SKILL_BUNDLE_ENABLED', 'flag.skill.bundle'), + }, // ---- Taxonomy ---- { key: 'SEMAPHORE_SYNC_ENABLED', label: 'Semaphore taxonomy auto-sync', category: 'Taxonomy', From c466c75f23198e47135384e1b87be14678f3541a Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:25:29 -0700 Subject: [PATCH 04/14] feat(2245): generate verify.sh from assert specs --- srv/lib/skill-bundle.js | 61 ++++++++++++++++++++++++++ test/unit/skill-bundle-compose.test.js | 55 +++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 srv/lib/skill-bundle.js create mode 100644 test/unit/skill-bundle-compose.test.js diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js new file mode 100644 index 000000000..12d667a34 --- /dev/null +++ b/srv/lib/skill-bundle.js @@ -0,0 +1,61 @@ +// srv/lib/skill-bundle.js +// GET /content/tutorials/:slug/skill — composes an installable agent Skill +// (SKILL.md + verify.sh) as a zip. Item 3 of #2245. Pure composition first, +// data + handler wiring below. + +const DEFAULT_BASE_URL = 'http://localhost:4004'; + +/** POSIX single-quote escape: a'b -> 'a'\''b' */ +export function shquote(s) { + return `'${String(s).replace(/'/g, `'\\''`)}'`; +} + +/** Build a runnable bash verifier from the tutorial's assert specs. */ +export function buildVerifyScript(asserts) { + const hasHttp = asserts.some((a) => a.type === 'http'); + const L = []; + L.push('#!/usr/bin/env bash'); + L.push('# Generated by SAP Tutorials — verifies this Skill against SAP\'s official steps.'); + L.push('set -euo pipefail'); + L.push(''); + if (hasHttp) L.push('BASE_URL="${BASE_URL:-' + DEFAULT_BASE_URL + '}"'); + L.push('fails=0'); + L.push('check() { if [ "$1" -eq 0 ]; then echo " PASS: $2"; else echo " FAIL: $2"; fails=$((fails+1)); fi; }'); + L.push(''); + + if (asserts.length === 0) { + L.push('echo "No automated checks defined for this tutorial."'); + L.push('exit 0'); + return L.join('\n') + '\n'; + } + + for (const a of asserts) { + const label = shquote(`step ${a.stepNumber} assert ${a.assertIndex} (${a.type})`); + L.push(`echo "Running check for ${a.type} @ step ${a.stepNumber}.${a.assertIndex}"`); + if (a.type === 'cmd') { + L.push('out=$(' + a.run + ' 2>&1) && rc=$? || rc=$?'); + L.push(`if [ "$rc" -eq ${Number(a.expectExit)} ]; then ok=0; else ok=1; fi`); + if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! printf '%s' "$out" | grep -Eq -- ${shquote(a.match)}; then ok=1; fi`); + L.push(`check "$ok" ${label} # ${shquote(a.run)}`); + } else if (a.type === 'http') { + L.push(`body=$(mktemp)`); + L.push(`code=$(curl -s -o "$body" -w '%{http_code}' -X ${a.method} "${'${BASE_URL}'}"${shquote(a.path)} || echo 000)`); + L.push(`if [ "$code" = ${shquote(String(a.expectStatus))} ]; then ok=0; else ok=1; fi`); + if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! grep -Eq -- ${shquote(a.match)} "$body"; then ok=1; fi`); + L.push(`rm -f "$body"`); + L.push(`check "$ok" ${label}`); + } else if (a.type === 'file') { + if (a.expectContains) { + L.push(`if [ -f ${shquote(a.filePath)} ] && grep -Eq -- ${shquote(a.match || '')} ${shquote(a.filePath)}; then ok=0; else ok=1; fi`); + } else { + L.push(`if test -f ${shquote(a.filePath)}; then ok=0; else ok=1; fi`); + } + L.push(`check "$ok" ${label}`); + } + L.push(''); + } + + L.push('if [ "$fails" -gt 0 ]; then echo "$fails check(s) failed."; exit 1; fi'); + L.push('echo "All checks passed."'); + return L.join('\n') + '\n'; +} diff --git a/test/unit/skill-bundle-compose.test.js b/test/unit/skill-bundle-compose.test.js new file mode 100644 index 000000000..2607b0b08 --- /dev/null +++ b/test/unit/skill-bundle-compose.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { buildVerifyScript, shquote } from '../../srv/lib/skill-bundle.js'; + +describe('shquote', () => { + it('wraps in single quotes and escapes embedded single quotes', () => { + expect(shquote(`a'b`)).toBe(`'a'\\''b'`); + expect(shquote('cds compile')).toBe(`'cds compile'`); + }); +}); + +describe('buildVerifyScript', () => { + it('emits a bash header with strict mode', () => { + const s = buildVerifyScript([]); + expect(s.startsWith('#!/usr/bin/env bash\n')).toBe(true); + expect(s).toContain('set -euo pipefail'); + }); + + it('empty asserts → notice + exit 0', () => { + const s = buildVerifyScript([]); + expect(s).toContain('No automated checks defined'); + expect(s.trimEnd().endsWith('exit 0')).toBe(true); + }); + + it('cmd assert runs the command and checks exit code + optional match', () => { + const s = buildVerifyScript([{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'cds compile', expectExit: 0, match: 'ok' }]); + expect(s).toContain(`'cds compile'`); + expect(s).toContain('-eq 0'); + expect(s).toContain('grep -Eq'); + }); + + it('http assert curls BASE_URL+path with method and checks status', () => { + const s = buildVerifyScript([{ stepNumber: 2, assertIndex: 0, type: 'http', method: 'GET', path: '/foo', expectStatus: 200 }]); + expect(s).toContain('BASE_URL="${BASE_URL:-http://localhost:4004}"'); + expect(s).toContain('-X GET'); + expect(s).toContain(shquote('/foo')); + expect(s).toContain('200'); + }); + + it('file exists vs contains', () => { + const exists = buildVerifyScript([{ stepNumber: 3, assertIndex: 0, type: 'file', filePath: 'a.cds', expectContains: false }]); + expect(exists).toContain('test -f'); + expect(exists).not.toContain('grep -Eq'); + const contains = buildVerifyScript([{ stepNumber: 3, assertIndex: 0, type: 'file', filePath: 'a.cds', expectContains: true, match: 'service' }]); + expect(contains).toContain('grep -Eq'); + }); + + it('preserves (stepNumber, assertIndex) order and fails overall when any check fails', () => { + const s = buildVerifyScript([ + { stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'a', expectExit: 0 }, + { stepNumber: 1, assertIndex: 1, type: 'cmd', run: 'b', expectExit: 0 }, + ]); + expect(s.indexOf("'a'")).toBeLessThan(s.indexOf("'b'")); + expect(s).toContain('exit 1'); + }); +}); From 43ead4c00a4cb5724b6a0a06b1bd31bf28f2dd03 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:29:41 -0700 Subject: [PATCH 05/14] feat(2245): compose SKILL.md from tutorial source + stamp --- srv/lib/skill-bundle.js | 47 ++++++++++++++++++++++++++ test/unit/skill-bundle-compose.test.js | 34 ++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index 12d667a34..6692336fe 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -3,6 +3,8 @@ // (SKILL.md + verify.sh) as a zip. Item 3 of #2245. Pure composition first, // data + handler wiring below. +import matter from 'gray-matter'; + const DEFAULT_BASE_URL = 'http://localhost:4004'; /** POSIX single-quote escape: a'b -> 'a'\''b' */ @@ -59,3 +61,48 @@ export function buildVerifyScript(asserts) { L.push('echo "All checks passed."'); return L.join('\n') + '\n'; } + +export function buildSkillMd({ slug, source, asserts, stamp }) { + let title = slug; + let description = ''; + let body = String(source || ''); + try { + const parsed = matter(String(source || '')); + title = parsed.data.title || slug; + description = parsed.data.description || ''; + body = parsed.content.trim(); + } catch { + body = String(source || '').trim(); + } + // single-line, quote-safe description for YAML + const desc = `${title}${description ? ' — ' + description : ''}`.replace(/\s+/g, ' ').replace(/"/g, "'").trim(); + + const fm = ['---', `name: ${slug}`, `description: "${desc}"`, '---', ''].join('\n'); + + const n = asserts.length; + const verifyLine = asserts.some((a) => a.type === 'http') + ? 'Run `BASE_URL= bash verify.sh` to check your work.' + : 'Run `bash verify.sh` to check your work.'; + + const provenance = [ + '## Provenance & freshness', + '', + `- confidence: ${stamp.confidence}`, + `- last-verified: ${stamp.lastVerified || 'unknown'}`, + `- source-commit: ${stamp.sourceCommit || 'unknown'}`, + '- source: sap-tutorials/Tutorials', + ]; + if (stamp.jws) { + provenance.push('', 'Signed attestation (verify against the JWKS at `/.well-known/tutorial-provenance/jwks.json`):', '', '```jws', stamp.jws, '```'); + } + + const verify = [ + '## Verifying this Skill', + '', + n === 0 + ? 'No automated checks are bundled with this tutorial. Follow the procedure above.' + : `${n} automated check(s) are bundled in \`verify.sh\`. ${verifyLine}`, + ]; + + return [fm, `# ${title}`, '', body, '', verify.join('\n'), '', provenance.join('\n'), ''].join('\n'); +} diff --git a/test/unit/skill-bundle-compose.test.js b/test/unit/skill-bundle-compose.test.js index 2607b0b08..67c66f398 100644 --- a/test/unit/skill-bundle-compose.test.js +++ b/test/unit/skill-bundle-compose.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildVerifyScript, shquote } from '../../srv/lib/skill-bundle.js'; +import { buildVerifyScript, shquote, buildSkillMd } from '../../srv/lib/skill-bundle.js'; describe('shquote', () => { it('wraps in single quotes and escapes embedded single quotes', () => { @@ -53,3 +53,35 @@ describe('buildVerifyScript', () => { expect(s).toContain('exit 1'); }); }); + +const SRC = `---\ntitle: Create a CAP Service\ndescription: Build and run a CAP service.\n---\n\n## Step 1\nDo the thing.\n`; + +describe('buildSkillMd', () => { + it('emits YAML frontmatter with name (slug) and description (from source)', () => { + const md = buildSkillMd({ slug: 'create-cap-service', source: SRC, asserts: [], stamp: { confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc123', jws: null } }); + expect(md).toMatch(/^---\n/); + expect(md).toContain('name: create-cap-service'); + expect(md).toContain('Create a CAP Service'); // description carried from source title/description + }); + + it('includes the procedure body (source minus frontmatter)', () => { + const md = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null } }); + expect(md).toContain('Do the thing.'); + expect(md).not.toContain('title: Create a CAP Service'); // frontmatter not duplicated into body + }); + + it('provenance section reflects the stamp and states check count', () => { + const md = buildSkillMd({ slug: 's', source: SRC, asserts: [{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'x', expectExit: 0 }], stamp: { confidence: 'medium', lastVerified: '2026-08-01', sourceCommit: 'deadbeef', jws: null } }); + expect(md).toContain('confidence: medium'); + expect(md).toContain('2026-08-01'); + expect(md).toContain('deadbeef'); + expect(md).toContain('1'); // one bundled check + }); + + it('includes the JWS fenced block only when present', () => { + const withJws = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc', jws: 'eyJ.sig' } }); + expect(withJws).toContain('eyJ.sig'); + const without = buildSkillMd({ slug: 's', source: SRC, asserts: [], stamp: { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null } }); + expect(without).not.toContain('```jws'); + }); +}); From 34dccfb2f8f3b0813281d33798bcecf1c3c7d77e Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:33:40 -0700 Subject: [PATCH 06/14] feat(2245): loadAssertSpecs reads + remaps AssertSpecs by slug --- srv/lib/skill-bundle.js | 27 ++++++++++++++++++++ test/unit/skill-bundle-data.test.js | 38 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 test/unit/skill-bundle-data.test.js diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index 6692336fe..6d16ae5c2 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -4,6 +4,7 @@ // data + handler wiring below. import matter from 'gray-matter'; +import cds from '@sap/cds'; const DEFAULT_BASE_URL = 'http://localhost:4004'; @@ -106,3 +107,29 @@ export function buildSkillMd({ slug, source, asserts, stamp }) { return [fm, `# ${title}`, '', body, '', verify.join('\n'), '', provenance.join('\n'), ''].join('\n'); } + +export async function loadAssertSpecs(slug) { + try { + const lc = String(slug || '').toLowerCase(); + const { Tutorials, AssertSpecs } = cds.entities('com.sap.developers.ims'); + const tut = await SELECT.one.from(Tutorials).columns('ID').where({ slug: lc }); + if (!tut) return []; + const rows = await SELECT.from(AssertSpecs).where({ tutorial_ID: tut.ID }).orderBy('stepNumber', 'assertIndex'); + return rows.map((r) => ({ + stepNumber: r.stepNumber, + assertIndex: r.assertIndex, + type: r.assertType, + run: r.run ?? undefined, + expectExit: r.expectExit ?? undefined, + method: r.httpMethod ?? undefined, + path: r.httpPath ?? undefined, + expectStatus: r.expectStatus ?? undefined, + filePath: r.filePath ?? undefined, + expectContains: typeof r.expectContains === 'boolean' ? r.expectContains : undefined, + match: r.matchRegex ?? undefined, + })); + } catch (e) { + console.warn('[skill-bundle] loadAssertSpecs fail-open:', e.message); + return []; + } +} diff --git a/test/unit/skill-bundle-data.test.js b/test/unit/skill-bundle-data.test.js new file mode 100644 index 000000000..e70fcea61 --- /dev/null +++ b/test/unit/skill-bundle-data.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import path from 'node:path'; +import cds from '@sap/cds'; +import { loadAssertSpecs } from '../../srv/lib/skill-bundle.js'; + +beforeAll(async () => { + await cds.deploy(path.join(process.cwd(), 'db', 'schema.cds')).to('sqlite::memory:'); +}); + +beforeEach(async () => { + const { AssertSpecs, Tutorials } = cds.entities('com.sap.developers.ims'); + await DELETE.from(AssertSpecs); + await DELETE.from(Tutorials); + await INSERT.into(Tutorials).entries([{ ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', slug: 'tutorial-alpha', title: 'Alpha', status: 'ACTIVE' }]); + await INSERT.into(AssertSpecs).entries([ + { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 2, assertIndex: 0, assertType: 'http', httpMethod: 'GET', httpPath: '/foo', expectStatus: 200 }, + { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 1, assertIndex: 1, assertType: 'cmd', run: 'b', expectExit: 0 }, + { tutorial_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', stepNumber: 1, assertIndex: 0, assertType: 'cmd', run: 'a', expectExit: 0, matchRegex: 'ok' }, + ]); +}); + +describe('loadAssertSpecs', () => { + it('returns [] for an unknown slug', async () => { + expect(await loadAssertSpecs('nope')).toEqual([]); + }); + + it('orders by (stepNumber, assertIndex) and remaps columns to logical names', async () => { + const specs = await loadAssertSpecs('tutorial-alpha'); + expect(specs.map((s) => `${s.stepNumber}.${s.assertIndex}`)).toEqual(['1.0', '1.1', '2.0']); + expect(specs[0]).toMatchObject({ type: 'cmd', run: 'a', expectExit: 0, match: 'ok' }); + expect(specs[2]).toMatchObject({ type: 'http', method: 'GET', path: '/foo', expectStatus: 200 }); + }); + + it('lowercases the slug before lookup', async () => { + const specs = await loadAssertSpecs('TUTORIAL-ALPHA'); + expect(specs).toHaveLength(3); + }); +}); From 55732378653daeb2704be5bec779c09b929cba61 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:37:42 -0700 Subject: [PATCH 07/14] feat(2245): freshness stamp (confidence + commit + optional jws) --- srv/lib/skill-bundle.js | 32 +++++++++++++++++++++++++ test/unit/skill-bundle-data.test.js | 36 ++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index 6d16ae5c2..6fd57ad18 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -5,6 +5,9 @@ import matter from 'gray-matter'; import cds from '@sap/cds'; +import { loadProvenanceInputs as _loadProvenanceInputs } from './provenance-data.js'; +import { deriveConfidence as _deriveConfidence } from './provenance-freshness.js'; +import { buildEnvelope as _buildEnvelope } from './provenance-envelope.js'; const DEFAULT_BASE_URL = 'http://localhost:4004'; @@ -133,3 +136,32 @@ export async function loadAssertSpecs(slug) { return []; } } + +export function makeFreshnessStampLoader({ loadProvenanceInputs, deriveConfidence, buildEnvelope }) { + return async function loadStamp(slug, { provenanceEnabled }) { + try { + const inputs = await loadProvenanceInputs(String(slug || '').toLowerCase()); + if (!inputs) return { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }; + const confidence = deriveConfidence({ report: inputs.report, now: Date.now() }); + const lastVerified = inputs.report?.runAt || inputs.builtAt || null; + let jws = null; + if (provenanceEnabled) { + try { + const env = await buildEnvelope({ slug: String(slug).toLowerCase(), ...inputs }); + jws = env?.jws || null; + } catch { jws = null; } + } + return { confidence, lastVerified, sourceCommit: inputs.sourceCommit || null, jws }; + } catch (e) { + console.warn('[skill-bundle] freshness stamp fail-open:', e.message); + return { confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }; + } + }; +} + +export const buildFreshnessStamp = (slug, opts) => + makeFreshnessStampLoader({ + loadProvenanceInputs: _loadProvenanceInputs, + deriveConfidence: _deriveConfidence, + buildEnvelope: _buildEnvelope, + })(slug, opts); diff --git a/test/unit/skill-bundle-data.test.js b/test/unit/skill-bundle-data.test.js index e70fcea61..76adb5295 100644 --- a/test/unit/skill-bundle-data.test.js +++ b/test/unit/skill-bundle-data.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import path from 'node:path'; import cds from '@sap/cds'; -import { loadAssertSpecs } from '../../srv/lib/skill-bundle.js'; +import { loadAssertSpecs, makeFreshnessStampLoader } from '../../srv/lib/skill-bundle.js'; beforeAll(async () => { await cds.deploy(path.join(process.cwd(), 'db', 'schema.cds')).to('sqlite::memory:'); @@ -36,3 +36,37 @@ describe('loadAssertSpecs', () => { expect(specs).toHaveLength(3); }); }); + +describe('freshness stamp', () => { + const inputs = { contentHash: 'h', sourceCommit: 'sha1', builtAt: '2026-09-01', report: { status: 'DONE', runAt: '2026-09-01', openHighCount: 0 } }; + + it('derives confidence + commit without a signing key, no jws when provenance disabled', async () => { + const load = makeFreshnessStampLoader({ + loadProvenanceInputs: async () => inputs, + deriveConfidence: () => 'high', + buildEnvelope: async () => ({ jws: 'should.not.appear' }), + }); + const stamp = await load('s', { provenanceEnabled: false }); + expect(stamp).toMatchObject({ confidence: 'high', sourceCommit: 'sha1', lastVerified: '2026-09-01', jws: null }); + }); + + it('adds jws when provenance enabled and envelope builds', async () => { + const load = makeFreshnessStampLoader({ + loadProvenanceInputs: async () => inputs, + deriveConfidence: () => 'high', + buildEnvelope: async () => ({ jws: 'eyJ.sig' }), + }); + const stamp = await load('s', { provenanceEnabled: true }); + expect(stamp.jws).toBe('eyJ.sig'); + }); + + it('fail-open to unknown when inputs are null', async () => { + const load = makeFreshnessStampLoader({ + loadProvenanceInputs: async () => null, + deriveConfidence: () => 'unknown', + buildEnvelope: async () => null, + }); + const stamp = await load('s', { provenanceEnabled: true }); + expect(stamp).toEqual({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }); + }); +}); From e0ac70812bf420e9bac3bd327f9331212ac7219c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 19:55:14 -0700 Subject: [PATCH 08/14] feat(2245): skill bundle handler streams SKILL.md + verify.sh zip --- srv/lib/skill-bundle.js | 69 ++++++++++++++++++++++++++ test/lib/skill-bundle-endpoint.test.js | 65 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/lib/skill-bundle-endpoint.test.js diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index 6fd57ad18..35cf61db0 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -5,9 +5,12 @@ import matter from 'gray-matter'; import cds from '@sap/cds'; +import { ZipArchive } from 'archiver'; import { loadProvenanceInputs as _loadProvenanceInputs } from './provenance-data.js'; import { deriveConfidence as _deriveConfidence } from './provenance-freshness.js'; import { buildEnvelope as _buildEnvelope } from './provenance-envelope.js'; +import { isFlagEnabled as _isFlagEnabled } from './feature-flags/db-flags.js'; +import { getTutorialSource as _getTutorialSource } from './content-store.js'; const DEFAULT_BASE_URL = 'http://localhost:4004'; @@ -165,3 +168,69 @@ export const buildFreshnessStamp = (slug, opts) => deriveConfidence: _deriveConfidence, buildEnvelope: _buildEnvelope, })(slug, opts); + +const VALID_SLUG = /^[a-z0-9]+(?:[-/][a-z0-9]+)*$/; + +export function createSkillBundleHandler(deps = {}) { + const { + isFlagEnabled = _isFlagEnabled, + getTutorialSource = _getTutorialSource, + loadAssertSpecs: _load = loadAssertSpecs, + buildFreshnessStamp: _stamp = buildFreshnessStamp, + provenanceFlagKey = 'PROVENANCE_ENVELOPE_ENABLED', + } = deps; + + return async function handler(req, res) { + if (!isFlagEnabled('SKILL_BUNDLE_ENABLED')) return res.status(404).end(); + + const raw = Array.isArray(req.params?.slug) ? req.params.slug.join('/') : req.params?.slug; + const slug = String(raw || '').replace(/\/$/, '').toLowerCase(); + if (!slug || !VALID_SLUG.test(slug)) return res.status(404).json({ error: 'not_found' }); + + const src = await getTutorialSource(slug); + if (!src || !src.markdown) return res.status(404).json({ error: 'not_found' }); + + const [asserts, stamp] = await Promise.all([ + _load(slug), + _stamp(slug, { provenanceEnabled: isFlagEnabled(provenanceFlagKey) }), + ]); + + const skillMd = buildSkillMd({ slug, source: src.markdown, asserts, stamp }); + const verifySh = buildVerifyScript(asserts); + + res.status(200); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug.replace(/\//g, '-')}-skill.zip"`); + res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600'); + + const archive = new ZipArchive({ zlib: { level: 9 } }); + + // Collect zip bytes via 'data' events (puts archive in flowing mode). + // Buffering the small text zip avoids readable-stream@4 ↔ native-Writable + // pipe backpressure incompatibilities in vitest workers, and is safe for + // production (SKILL.md + verify.sh are always <64 KB). + const bufferPromise = new Promise((resolve, reject) => { + const chunks = []; + archive.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + archive.on('end', () => resolve(Buffer.concat(chunks))); + archive.on('error', reject); + }); + + const dir = slug.replace(/\//g, '-'); + archive.append(skillMd, { name: `${dir}/SKILL.md` }); + archive.append(verifySh, { name: `${dir}/verify.sh`, mode: 0o755 }); + archive.finalize(); // fire-and-forget; 'data'/'end'/'error' drive completion + + let buf; + try { + buf = await bufferPromise; + } catch (err) { + console.error('[skill-bundle] archive error:', err.message); + if (!res.headersSent) res.status(500).end(); + return; + } + res.end(buf); + }; +} + +export const skillBundleHandler = createSkillBundleHandler(); diff --git a/test/lib/skill-bundle-endpoint.test.js b/test/lib/skill-bundle-endpoint.test.js new file mode 100644 index 000000000..86b353a1f --- /dev/null +++ b/test/lib/skill-bundle-endpoint.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { Writable } from 'node:stream'; +import JSZip from 'jszip'; +import { createSkillBundleHandler } from '../../srv/lib/skill-bundle.js'; + +// Collect the streamed zip into a buffer via a fake res that is a Writable. +function fakeRes() { + const chunks = []; + const res = new Writable({ write(c, _e, cb) { chunks.push(Buffer.from(c)); cb(); } }); + res.statusCode = 200; + res.headers = {}; + res.setHeader = (k, v) => { res.headers[k.toLowerCase()] = v; }; + res.status = (c) => { res.statusCode = c; return res; }; + res.json = (b) => { res.jsonBody = b; res.end(); return res; }; + res.buffer = () => Buffer.concat(chunks); + return res; +} +const req = (slug) => ({ params: { slug } }); + +const baseDeps = { + isFlagEnabled: () => true, + getTutorialSource: async () => ({ markdown: `---\ntitle: T\ndescription: D\n---\n\n## Step 1\nBody.\n` }), + loadAssertSpecs: async () => [{ stepNumber: 1, assertIndex: 0, type: 'cmd', run: 'x', expectExit: 0 }], + buildFreshnessStamp: async () => ({ confidence: 'high', lastVerified: '2026-09-01', sourceCommit: 'abc', jws: null }), + provenanceFlagKey: 'PROVENANCE_ENVELOPE_ENABLED', +}; + +describe('skill bundle handler', () => { + it('404 when feature flag is off', async () => { + const res = fakeRes(); + await createSkillBundleHandler({ ...baseDeps, isFlagEnabled: () => false })(req('t'), res); + expect(res.statusCode).toBe(404); + }); + + it('404 when the slug has no source markdown', async () => { + const res = fakeRes(); + await createSkillBundleHandler({ ...baseDeps, getTutorialSource: async () => ({ markdown: null }) })(req('nope'), res); + expect(res.statusCode).toBe(404); + }); + + it('200 streams a zip with SKILL.md and verify.sh', async () => { + const res = fakeRes(); + await createSkillBundleHandler(baseDeps)(req('my-tutorial'), res); + await new Promise((r) => res.on('finish', r)); + expect(res.headers['content-type']).toContain('application/zip'); + expect(res.headers['content-disposition']).toContain('my-tutorial-skill.zip'); + const zip = await JSZip.loadAsync(res.buffer()); + expect(zip.file('my-tutorial/SKILL.md')).toBeTruthy(); + const verify = await zip.file('my-tutorial/verify.sh').async('string'); + expect(verify).toContain('#!/usr/bin/env bash'); + expect(verify).toContain("'x'"); + const skill = await zip.file('my-tutorial/SKILL.md').async('string'); + expect(skill).toContain('name: my-tutorial'); + expect(skill).toContain('confidence: high'); + }); + + it('still ships (degraded) when provenance/asserts are empty', async () => { + const res = fakeRes(); + await createSkillBundleHandler({ ...baseDeps, loadAssertSpecs: async () => [], buildFreshnessStamp: async () => ({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }) })(req('t'), res); + await new Promise((r) => res.on('finish', r)); + const zip = await JSZip.loadAsync(res.buffer()); + const verify = await zip.file('t/verify.sh').async('string'); + expect(verify).toContain('No automated checks defined'); + }); +}); From fe2e5369ed6344cd328ab8cf9adb63e277390bb3 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 20:00:30 -0700 Subject: [PATCH 09/14] fix(2245): 301 redirect on non-canonical slug + injectable buildSkillMd/buildVerifyScript --- srv/lib/skill-bundle.js | 17 +++++++++++++---- test/lib/skill-bundle-endpoint.test.js | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index 35cf61db0..06c3ea07e 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -177,6 +177,8 @@ export function createSkillBundleHandler(deps = {}) { getTutorialSource = _getTutorialSource, loadAssertSpecs: _load = loadAssertSpecs, buildFreshnessStamp: _stamp = buildFreshnessStamp, + buildSkillMd: _buildSkillMd = buildSkillMd, + buildVerifyScript: _buildVerifyScript = buildVerifyScript, provenanceFlagKey = 'PROVENANCE_ENVELOPE_ENABLED', } = deps; @@ -184,8 +186,15 @@ export function createSkillBundleHandler(deps = {}) { if (!isFlagEnabled('SKILL_BUNDLE_ENABLED')) return res.status(404).end(); const raw = Array.isArray(req.params?.slug) ? req.params.slug.join('/') : req.params?.slug; - const slug = String(raw || '').replace(/\/$/, '').toLowerCase(); - if (!slug || !VALID_SLUG.test(slug)) return res.status(404).json({ error: 'not_found' }); + const canonical = String(raw || '').replace(/\/$/, '').toLowerCase(); + if (!canonical || !VALID_SLUG.test(canonical)) return res.status(404).json({ error: 'not_found' }); + + // 301 redirect when the incoming slug differs from its canonical (lowercase) form + if (String(raw) !== canonical) { + res.setHeader('Location', `/content/tutorials/${canonical}/skill`); + return res.status(301).end(); + } + const slug = canonical; const src = await getTutorialSource(slug); if (!src || !src.markdown) return res.status(404).json({ error: 'not_found' }); @@ -195,8 +204,8 @@ export function createSkillBundleHandler(deps = {}) { _stamp(slug, { provenanceEnabled: isFlagEnabled(provenanceFlagKey) }), ]); - const skillMd = buildSkillMd({ slug, source: src.markdown, asserts, stamp }); - const verifySh = buildVerifyScript(asserts); + const skillMd = _buildSkillMd({ slug, source: src.markdown, asserts, stamp }); + const verifySh = _buildVerifyScript(asserts); res.status(200); res.setHeader('Content-Type', 'application/zip'); diff --git a/test/lib/skill-bundle-endpoint.test.js b/test/lib/skill-bundle-endpoint.test.js index 86b353a1f..180758e09 100644 --- a/test/lib/skill-bundle-endpoint.test.js +++ b/test/lib/skill-bundle-endpoint.test.js @@ -54,6 +54,14 @@ describe('skill bundle handler', () => { expect(skill).toContain('confidence: high'); }); + it('301 redirect when slug is non-canonical (mixed case)', async () => { + const res = fakeRes(); + await createSkillBundleHandler(baseDeps)(req('My-Tutorial'), res); + expect(res.statusCode).toBe(301); + expect(res.headers['location']).toContain('my-tutorial'); + expect(res.headers['location']).toMatch(/\/skill$/); + }); + it('still ships (degraded) when provenance/asserts are empty', async () => { const res = fakeRes(); await createSkillBundleHandler({ ...baseDeps, loadAssertSpecs: async () => [], buildFreshnessStamp: async () => ({ confidence: 'unknown', lastVerified: null, sourceCommit: null, jws: null }) })(req('t'), res); From d2e136534493b956eb1b7305f412bc0360bdc458 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 20:06:15 -0700 Subject: [PATCH 10/14] feat(2245): register GET /content/tutorials/:slug/skill route --- srv/server.js | 2 ++ test/smoke/express-route-mutations.test.js | 26 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/srv/server.js b/srv/server.js index 21e5b8d93..c2e2fc81b 100644 --- a/srv/server.js +++ b/srv/server.js @@ -99,6 +99,7 @@ import { makeA2aRouter } from './lib/a2a/rpc-router.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'; +import { skillBundleHandler } from './lib/skill-bundle.js'; // #1182 — cds-caching resolve-guard fix. This module is evaluated by cds-serve // AFTER `await cds.plugins` (so the cds-caching plugin has already pushed its @@ -763,6 +764,7 @@ cds.on('bootstrap', (app) => { // below so `demo/provenance` is not swallowed as a slug. Public, read-only — no // auth; these are attestation/key-distribution endpoints. app.get('/content/tutorials/:slug/provenance', provenanceHandler); + app.get('/content/tutorials/:slug/skill', skillBundleHandler); app.get('/content/tutorials/*slug', serveHandler); // Legacy AEM `.model.json` compatibility for SAP Discovery Center (#DC cards). // Approuter maps ^/tutorials/.model.json$ → here. See srv/lib/model-json.js. diff --git a/test/smoke/express-route-mutations.test.js b/test/smoke/express-route-mutations.test.js index daa6872a1..21ed419c5 100644 --- a/test/smoke/express-route-mutations.test.js +++ b/test/smoke/express-route-mutations.test.js @@ -101,5 +101,31 @@ describe.skipIf(!SRV_URL || SRV_URL.startsWith('http://localhost'))( expect([200, 204, 400, 413]).toContain(res.status); }); }); + + describe('GET routes registered before the *slug wildcard (#2245)', () => { + // These routes MUST be registered before app.get('/content/tutorials/*slug', + // serveHandler) in srv/server.js. If the wildcard swallows them they return + // text/html instead of their own response type. + const getRoutesBeforeWildcard = [ + 'GET /content/tutorials/:slug/provenance', + 'GET /content/tutorials/:slug/skill', + ]; + it.each(getRoutesBeforeWildcard)( + '%s is registered before wildcard (non-HTML response for unknown slug)', + async (routeSpec) => { + const path = routeSpec + .replace('GET ', '') + .replace(':slug', '__smoke_no_such_slug__'); + const res = await fetchWithRetry(`${SRV_URL}${path}`); + // The wildcard serveHandler returns text/html for all slugs (even 404s). + // A dedicated handler returns application/json, application/zip, or similar. + // Either a non-2xx status OR a non-HTML content-type confirms the route fires. + const ct = res.headers.get('content-type') ?? ''; + const isHtml = ct.startsWith('text/html'); + // If the response is HTML and 200, the wildcard swallowed it — that's a bug. + expect(isHtml && res.status === 200).toBe(false); + }, + ); + }); } ); From f39f9938d791450cc3014c2441236b124343d9e1 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 20:11:48 -0700 Subject: [PATCH 11/14] fix(2245): tighten route-ordering smoke assertion to any-HTML --- test/smoke/express-route-mutations.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/smoke/express-route-mutations.test.js b/test/smoke/express-route-mutations.test.js index 21ed419c5..909e61167 100644 --- a/test/smoke/express-route-mutations.test.js +++ b/test/smoke/express-route-mutations.test.js @@ -117,13 +117,13 @@ describe.skipIf(!SRV_URL || SRV_URL.startsWith('http://localhost'))( .replace('GET ', '') .replace(':slug', '__smoke_no_such_slug__'); const res = await fetchWithRetry(`${SRV_URL}${path}`); - // The wildcard serveHandler returns text/html for all slugs (even 404s). - // A dedicated handler returns application/json, application/zip, or similar. - // Either a non-2xx status OR a non-HTML content-type confirms the route fires. + // The wildcard serveHandler returns text/html for ALL slugs (including 404s). + // A dedicated handler returns application/json, application/zip, or similar — + // never text/html. const ct = res.headers.get('content-type') ?? ''; const isHtml = ct.startsWith('text/html'); - // If the response is HTML and 200, the wildcard swallowed it — that's a bug. - expect(isHtml && res.status === 200).toBe(false); + // Any HTML content-type means the wildcard swallowed the request — that's a bug. + expect(isHtml).toBe(false); }, ); }); From a879170ec298c7bde2d32ecb076dc267217cd807 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 20:14:02 -0700 Subject: [PATCH 12/14] feat(2245): allowlist /skill as srv-only in route-drift guard --- scripts/check-srv-qa-route-drift.ts | 7 +++++++ test/unit/check-srv-qa-route-drift.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/check-srv-qa-route-drift.ts b/scripts/check-srv-qa-route-drift.ts index 608728998..cb1be2677 100644 --- a/scripts/check-srv-qa-route-drift.ts +++ b/scripts/check-srv-qa-route-drift.ts @@ -79,6 +79,13 @@ const ALLOWLIST_ONLY_ON_SRV: Record = { 'requireAuthorScope (author-draft preview), the PROVENANCE_SIGNING_KEY credstore secret is ' + 'not provisioned for srv-qa, and provenance is meaningful only for published content, not ' + 'in-flight -Contribution drafts. Re-evaluate if QA ever gains a published-content trust surface.', + 'GET /content/tutorials/:slug/skill': + 'Installable Skill bundle (#2245) — an anonymous, public, read-only prod content ' + + 'surface that streams a zip (SKILL.md + verify.sh from assert blocks + provenance stamp) ' + + 'over PUBLISHED tutorials. Feature-flagged (SKILL_BUNDLE_ENABLED, DB config, default OFF, ' + + 'DEV-first) and fail-open. Not a QA-channel surface: srv-qa serves tutorials behind ' + + 'requireAuthorScope (author-draft preview) and the Skill bundle is meaningful only for ' + + 'published content. Mirror of the /provenance allowlist rationale.', 'POST /content/code-check-specs': 'AI code-check (#171) — gated behind ChatSettings.codeCheckEnabled feature flag; ' + 'not yet wired for QA author-preview. Re-evaluate when credstore-backed ChatSettings ' + diff --git a/test/unit/check-srv-qa-route-drift.test.ts b/test/unit/check-srv-qa-route-drift.test.ts index d51e05221..2366f5127 100644 --- a/test/unit/check-srv-qa-route-drift.test.ts +++ b/test/unit/check-srv-qa-route-drift.test.ts @@ -208,6 +208,21 @@ describe('scripts/check-srv-qa-route-drift.ts', () => { expect(r.status).toBe(0); }); + it('respects the ALLOWLIST_ONLY_ON_SRV entry for skill bundle', () => { + // /content/tutorials/:slug/skill is intentionally srv-only per the + // hard-coded allowlist in the script (#2245). A srv that has it and a + // srv-qa that doesn't should pass — that's the allowlist's job. + writeServer(root, 'srv', ` + app.get('/content/nav', navHandler); + app.get('/content/tutorials/:slug/skill', skillBundleHandler); + `); + writeServer(root, 'srv-qa', ` + app.get('/content/nav', requireAuthorScope, navHandler); + `); + const r = run(root); + expect(r.status).toBe(0); + }); + it('parses a route whose literal contains "/*" (e.g. *slug) without swallowing the file', () => { // Regression for the string-unaware comment stripper: '/content/tutorials/*slug' // contains a `/*` sequence inside the string literal. The old stripper mistook From 94b3030a66b0b7d7a0dd9d4e7356f99dc206cbb3 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 20:39:05 -0700 Subject: [PATCH 13/14] fix(2245): shell-quote curl -X method + echo label in verify.sh (injection) --- srv/lib/skill-bundle.js | 4 ++-- test/unit/skill-bundle-compose.test.js | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index 06c3ea07e..c4ecb66d7 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -40,7 +40,7 @@ export function buildVerifyScript(asserts) { for (const a of asserts) { const label = shquote(`step ${a.stepNumber} assert ${a.assertIndex} (${a.type})`); - L.push(`echo "Running check for ${a.type} @ step ${a.stepNumber}.${a.assertIndex}"`); + L.push(`echo ${shquote(`Running check for ${a.type} @ step ${a.stepNumber}.${a.assertIndex}`)}`); if (a.type === 'cmd') { L.push('out=$(' + a.run + ' 2>&1) && rc=$? || rc=$?'); L.push(`if [ "$rc" -eq ${Number(a.expectExit)} ]; then ok=0; else ok=1; fi`); @@ -48,7 +48,7 @@ export function buildVerifyScript(asserts) { L.push(`check "$ok" ${label} # ${shquote(a.run)}`); } else if (a.type === 'http') { L.push(`body=$(mktemp)`); - L.push(`code=$(curl -s -o "$body" -w '%{http_code}' -X ${a.method} "${'${BASE_URL}'}"${shquote(a.path)} || echo 000)`); + L.push(`code=$(curl -s -o "$body" -w '%{http_code}' -X ${shquote(a.method)} "${'${BASE_URL}'}"${shquote(a.path)} || echo 000)`); L.push(`if [ "$code" = ${shquote(String(a.expectStatus))} ]; then ok=0; else ok=1; fi`); if (a.match) L.push(`if [ "$ok" -eq 0 ] && ! grep -Eq -- ${shquote(a.match)} "$body"; then ok=1; fi`); L.push(`rm -f "$body"`); diff --git a/test/unit/skill-bundle-compose.test.js b/test/unit/skill-bundle-compose.test.js index 67c66f398..152232256 100644 --- a/test/unit/skill-bundle-compose.test.js +++ b/test/unit/skill-bundle-compose.test.js @@ -6,6 +6,12 @@ describe('shquote', () => { expect(shquote(`a'b`)).toBe(`'a'\\''b'`); expect(shquote('cds compile')).toBe(`'cds compile'`); }); + + it('preserves dollar signs and backticks inside single quotes so they cannot execute at runtime', () => { + // a'b$(x)`y → 'a'\''b$(x)`y' + // The $ and ` land inside single-quoted segments and are shell-inert + expect(shquote("a'b$(x)`y")).toBe("'a'\\''b$(x)`y'"); + }); }); describe('buildVerifyScript', () => { @@ -31,7 +37,7 @@ describe('buildVerifyScript', () => { it('http assert curls BASE_URL+path with method and checks status', () => { const s = buildVerifyScript([{ stepNumber: 2, assertIndex: 0, type: 'http', method: 'GET', path: '/foo', expectStatus: 200 }]); expect(s).toContain('BASE_URL="${BASE_URL:-http://localhost:4004}"'); - expect(s).toContain('-X GET'); + expect(s).toContain("-X 'GET'"); expect(s).toContain(shquote('/foo')); expect(s).toContain('200'); }); @@ -52,6 +58,22 @@ describe('buildVerifyScript', () => { expect(s.indexOf("'a'")).toBeLessThan(s.indexOf("'b'")); expect(s).toContain('exit 1'); }); + + it('shell-quotes curl -X method to prevent shell injection via a hostile method value', () => { + const s = buildVerifyScript([{ stepNumber: 1, assertIndex: 0, type: 'http', method: "GET;rm -rf ~ #", path: '/x', expectStatus: 200 }]); + // The injected semicolon must be inside single quotes so it cannot start a new command + expect(s).toContain("-X 'GET;rm -rf ~ #'"); + // An unquoted -X GET; sequence that would let rm run must NOT appear + expect(s).not.toMatch(/-X GET;/); + }); + + it('shell-quotes the echo label to prevent command substitution via type or step metadata', () => { + const s = buildVerifyScript([{ stepNumber: 1, assertIndex: 0, type: 'cmd$(evil)', run: 'true', expectExit: 0 }]); + // The label must appear as a single-quoted string, not a double-quoted one that would expand $() + expect(s).toContain("echo 'Running check for cmd$(evil) @ step 1.0'"); + // The per-assert echo must NOT use a double-quoted string (which would live-expand $(evil)) + expect(s).not.toContain('echo "Running check for'); + }); }); const SRC = `---\ntitle: Create a CAP Service\ndescription: Build and run a CAP service.\n---\n\n## Step 1\nDo the thing.\n`; From d96acd0c4d33ea0d9c12d89e86fd8c1c40e7d8f8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 11 Sep 2026 20:52:58 -0700 Subject: [PATCH 14/14] fix(2245): rename lc->lcSlug in loadAssertSpecs for slug-lookup guard --- srv/lib/skill-bundle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/srv/lib/skill-bundle.js b/srv/lib/skill-bundle.js index c4ecb66d7..5b28c150e 100644 --- a/srv/lib/skill-bundle.js +++ b/srv/lib/skill-bundle.js @@ -116,9 +116,9 @@ export function buildSkillMd({ slug, source, asserts, stamp }) { export async function loadAssertSpecs(slug) { try { - const lc = String(slug || '').toLowerCase(); + const lcSlug = String(slug || '').toLowerCase(); const { Tutorials, AssertSpecs } = cds.entities('com.sap.developers.ims'); - const tut = await SELECT.one.from(Tutorials).columns('ID').where({ slug: lc }); + const tut = await SELECT.one.from(Tutorials).columns('ID').where({ slug: lcSlug }); if (!tut) return []; const rows = await SELECT.from(AssertSpecs).where({ tutorial_ID: tut.ID }).orderBy('stepNumber', 'assertIndex'); return rows.map((r) => ({