From 4dfecf54ba60ab2450afccc80196ae1fce30d5bf Mon Sep 17 00:00:00 2001 From: studiolxd Date: Sun, 20 Sep 2026 11:05:13 +0200 Subject: [PATCH 1/2] fix(test): make the tampered-signature assertion deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test flipped the LAST base64url character of the JWS to simulate tampering. For a 256-byte RS256 signature the final group encodes a single byte, so that character carries only two significant bits plus discarded padding — flipping it decoded to identical bytes roughly 25% of the time, leaving the signature valid and failing the assertion. Tamper with the first signature character instead, which always carries six significant bits. Verified over 12 consecutive runs (previously ~3 failures expected). Pre-existing flake, unrelated to any dependency change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011kohSPc6KNpFd9EiV8Vbez --- packages/xapi/tests/server/jws.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/xapi/tests/server/jws.test.ts b/packages/xapi/tests/server/jws.test.ts index 4dec223..ad4e837 100644 --- a/packages/xapi/tests/server/jws.test.ts +++ b/packages/xapi/tests/server/jws.test.ts @@ -37,9 +37,14 @@ describe('verifySignedStatement', () => { it('rejects a tampered signature', async () => { const { jws, publicKey } = await signJws('RS256', {}, { hello: 'world' }); - const lastChar = jws.slice(-1); - const swapped = lastChar === 'A' ? 'B' : 'A'; - const tampered = jws.slice(0, -1) + swapped; + const [headerB64, payloadB64, signatureB64] = jws.split('.'); + // Tamper with the FIRST signature character, which always carries six + // significant bits. The last character of a 256-byte (RS256) base64url + // signature carries only two significant bits plus discarded padding, so + // flipping it decodes to the same bytes ~25% of the time, leaving the + // signature intact and the assertion flaky. + const swapped = signatureB64[0] === 'A' ? 'B' : 'A'; + const tampered = `${headerB64}.${payloadB64}.${swapped}${signatureB64.slice(1)}`; const result = await verifySignedStatement(tampered, { allowedAlgorithms: ['RS256'], resolveKey: async () => publicKey, From 4fcd05e495bdd4ed10e30daee8f11686c5b20415 Mon Sep 17 00:00:00 2001 From: studiolxd Date: Sun, 20 Sep 2026 11:06:18 +0200 Subject: [PATCH 2/2] ci: run typecheck, tests and builds from a root workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ci.yml files under packages/*/.github (and example/.github in scorm) were never executed: GitHub Actions only reads .github/workflows from the repository root. They were leftovers from when those directories were standalone repos, so typecheck, tests and builds had no CI coverage at all — only the Angular smoke test ran. Consolidate them into a single root workflow across Node 20 and 22. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011kohSPc6KNpFd9EiV8Vbez --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++ packages/xapi/.github/workflows/ci.yml | 32 ----------------- 2 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 packages/xapi/.github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a78290 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +# Consolidates the ci.yml that used to live in packages/xapi/.github. GitHub +# Actions only reads .github/workflows from the repository ROOT, so it never +# ran: typecheck, tests and builds were not validated by CI at all. + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: Typecheck, test & build (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + + - name: Typecheck (library) + run: npm run typecheck --workspace=packages/xapi + + - name: Test (library) + run: npm run test:run --workspace=packages/xapi + + - name: Build (library) + run: npm run build --workspace=packages/xapi + + - name: Lint (example) + run: npm run lint --workspace=example + + # After the library build: the example resolves @studiolxd/xapi through + # the workspace, whose `exports` point at dist/. + - name: Build (example) + run: npm run build --workspace=example diff --git a/packages/xapi/.github/workflows/ci.yml b/packages/xapi/.github/workflows/ci.yml deleted file mode 100644 index e6d1f8e..0000000 --- a/packages/xapi/.github/workflows/ci.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - ci: - name: Typecheck, Test & Build - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm run test:run - - - name: Build - run: npm run build