From 3e9082d820fca451ec53b8f2998469c98abf6865 Mon Sep 17 00:00:00 2001 From: Mazze LeCzzare Date: Tue, 4 Aug 2026 05:50:57 -0400 Subject: [PATCH] fix: resolve remaining advisory, derive version from package.json, test stele-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Dependabot.** #42 cleared the three stele-core advisories, including the Hono CORS ReDoS, which mattered here because that middleware is what guards /api/*. The last one was @babel/core, transitive via eslint-plugin-react-hooks — lint and build time only, never in bundle.html, hence LOW. Pinned with a pnpm override to ^7.29.7 rather than left open; the lockfile now resolves 7.29.7. **Version.** This was not an ambiguity, it was rot. v1.1.0 was tagged and released on 2026-06-06, package.json was bumped for it, and src/lib/version.ts was not — zero commits touched it between the two tags. Since STELE_VERSION is stamped into every narrative export as `steleVersion`, exports from the shipped 1.1.0 build claimed to be 1.0.0. package.json is authoritative because it is what the tag and release.yml publish against, so the constant is now injected from it by vite's `define` and cannot drift again. A test asserts the wiring holds, since a removed define would silently make it undefined rather than fail. **stele-core tests.** It had none — the reason it reached main with live type errors, and the reason the injectivity fix was only ever covered on the browser side. 39 cases over the chain encoding, the /api/* perimeter, the schema invariants, and the write-conflict classifier. No database needed: the chain is pure and the perimeter goes through Hono's request handler, so the suite runs in CI without a service container. node:test rather than vitest, deliberately. tsx is already a devDependency, so this adds zero packages to a security-sensitive service whose own review flagged install-time supply-chain risk. Mutation-tested rather than assumed: dropping the P2034 check fails 2, letting an empty API_SECRET fall open fails 1, reverting the chain to a delimiter join fails 1. The suite discriminates. --- .github/workflows/typecheck.yml | 4 + package.json | 5 + pnpm-lock.yaml | 171 +++++++++++++++----------- src/lib/__tests__/version.test.ts | 19 +++ src/lib/version.ts | 9 +- stele-core/package.json | 2 +- stele-core/test/auth.test.ts | 115 +++++++++++++++++ stele-core/test/chain.test.ts | 147 ++++++++++++++++++++++ stele-core/test/schemas.test.ts | 45 +++++++ stele-core/test/serialization.test.ts | 62 ++++++++++ vite.config.ts | 10 ++ 11 files changed, 518 insertions(+), 71 deletions(-) create mode 100644 src/lib/__tests__/version.test.ts create mode 100644 stele-core/test/auth.test.ts create mode 100644 stele-core/test/chain.test.ts create mode 100644 stele-core/test/schemas.test.ts create mode 100644 stele-core/test/serialization.test.ts diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 48f1ced..f7a24da 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -44,3 +44,7 @@ jobs: env: DATABASE_URL: postgresql://ci:ci@localhost:5432/ci - run: npx tsc --noEmit + # No database required — the suite covers the chain encoding, the /api/* + # perimeter, the schema invariants, and the write-conflict classifier, + # all of which are pure or exercised through Hono's request handler. + - run: npm test diff --git a/package.json b/package.json index a58a593..346b45a 100644 --- a/package.json +++ b/package.json @@ -80,5 +80,10 @@ "vite": "^8.2.0", "vite-plugin-singlefile": "^2.3.3", "vitest": "^4.1.10" + }, + "pnpm": { + "overrides": { + "@babel/core": "^7.29.7" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a5c582..dfd941b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@babel/core': ^7.29.7 + importers: .: @@ -211,54 +214,62 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^7.29.7 '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.2': @@ -266,18 +277,27 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -2840,25 +2860,25 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -2868,69 +2888,77 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.6 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 - '@babel/template@7.28.6': + '@babel/parser@7.29.8': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.0': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -2940,6 +2968,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@date-fns/tz@1.4.1': {} '@dnd-kit/accessibility@3.1.1(react@19.2.5)': @@ -4565,7 +4598,7 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@10.8.0(jiti@2.7.0)): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.2 eslint: 10.8.0(jiti@2.7.0) hermes-parser: 0.25.1 diff --git a/src/lib/__tests__/version.test.ts b/src/lib/__tests__/version.test.ts new file mode 100644 index 0000000..3936f44 --- /dev/null +++ b/src/lib/__tests__/version.test.ts @@ -0,0 +1,19 @@ +// Guards the wiring, not the number. STELE_VERSION is injected by vite's +// `define` from package.json; if that block is removed or renamed the constant +// silently becomes undefined and every narrative export ships without +// provenance. package.json is authoritative because it is what `git tag v*` +// and release.yml publish against. +import { describe, expect, it } from 'vitest' +import { STELE_VERSION } from '../version' +import pkg from '../../../package.json' + +describe('STELE_VERSION', () => { + it('is injected, not undefined', () => { + expect(STELE_VERSION).toBeTypeOf('string') + expect(STELE_VERSION).toMatch(/^\d+\.\d+\.\d+/) + }) + + it('matches the version release.yml actually publishes', () => { + expect(STELE_VERSION).toBe(pkg.version) + }) +}) diff --git a/src/lib/version.ts b/src/lib/version.ts index c3aa1e7..7a99bc6 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1 +1,8 @@ -export const STELE_VERSION = '1.0.0' +// Injected by vite from package.json — see the `define` block in vite.config.ts. +// Do not replace this with a literal. The previous hand-maintained constant +// missed the 1.1.0 release and stamped stale provenance into every narrative +// export, which is the one artifact where a wrong version actively misleads +// rather than merely being cosmetic. +declare const __STELE_VERSION__: string + +export const STELE_VERSION = __STELE_VERSION__ diff --git a/stele-core/package.json b/stele-core/package.json index 39335ad..1bae446 100644 --- a/stele-core/package.json +++ b/stele-core/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx watch index.ts", "start": "node --import tsx/esm index.ts", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --import tsx/esm --test \"test/*.test.ts\"" }, "keywords": [], "author": "Mazze LeCzzare", diff --git a/stele-core/test/auth.test.ts b/stele-core/test/auth.test.ts new file mode 100644 index 0000000..cf694d9 --- /dev/null +++ b/stele-core/test/auth.test.ts @@ -0,0 +1,115 @@ +// The /api/* perimeter, exercised through Hono's request handler rather than a +// live socket. These assertions were previously only ever made by hand against +// a running server, which means they were true once rather than true always. + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { requireBearer } from "../src/middleware/auth.js"; + +const SECRET = "test-secret-not-a-real-credential"; + +// Mirrors server.ts ordering: cors first, then the perimeter, then routes. +function makeApp() { + const app = new Hono(); + app.use("/api/*", cors({ + origin: ["http://localhost:5173"], + allowMethods: ["GET", "POST", "PATCH", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], + })); + app.use("/api/*", requireBearer); + app.get("/api/thing", (c) => c.json({ ok: true })); + app.get("/health", (c) => c.json({ status: "ok" })); + return app; +} + +const bearer = (token: string) => ({ headers: { Authorization: `Bearer ${token}` } }); + +let saved: string | undefined; +beforeEach(() => { + saved = process.env.API_SECRET; + process.env.API_SECRET = SECRET; +}); +afterEach(() => { + if (saved === undefined) delete process.env.API_SECRET; + else process.env.API_SECRET = saved; +}); + +describe("requireBearer", () => { + it("allows a correct token through", async () => { + const res = await makeApp().request("/api/thing", bearer(SECRET)); + assert.equal(res.status, 200); + }); + + it("rejects a missing Authorization header", async () => { + const res = await makeApp().request("/api/thing"); + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: "unauthorized" }); + }); + + it("rejects a wrong token", async () => { + const res = await makeApp().request("/api/thing", bearer("wrong")); + assert.equal(res.status, 401); + }); + + it("rejects a token that is a prefix of the real one", async () => { + // Guards the constant-time compare: a length mismatch must not be treated + // as a partial match. + const res = await makeApp().request("/api/thing", bearer(SECRET.slice(0, -1))); + assert.equal(res.status, 401); + }); + + it("rejects a token with trailing content appended", async () => { + const res = await makeApp().request("/api/thing", bearer(SECRET + "x")); + assert.equal(res.status, 401); + }); + + it("rejects a non-Bearer scheme carrying the right secret", async () => { + const res = await makeApp().request("/api/thing", { + headers: { Authorization: `Basic ${SECRET}` }, + }); + assert.equal(res.status, 401); + }); + + it("rejects a bare token with no scheme", async () => { + const res = await makeApp().request("/api/thing", { headers: { Authorization: SECRET } }); + assert.equal(res.status, 401); + }); + + it("fails closed when API_SECRET is unset — never falls open", async () => { + delete process.env.API_SECRET; + const res = await makeApp().request("/api/thing", bearer(SECRET)); + assert.equal(res.status, 500); + assert.deepEqual(await res.json(), { error: "server_not_configured" }); + }); + + it("fails closed when API_SECRET is empty", async () => { + // dotenv assigns "" for a bare `API_SECRET=` line — the same empty-value + // trap that made HOST bind every interface. Empty must mean unset. + process.env.API_SECRET = ""; + const res = await makeApp().request("/api/thing"); + assert.equal(res.status, 500); + }); + + it("leaves routes outside /api/* alone", async () => { + const res = await makeApp().request("/health"); + assert.equal(res.status, 200); + }); + + it("answers the CORS preflight without a token", async () => { + // Browsers do not send Authorization on a preflight; if cors() did not + // short-circuit before the perimeter, every authenticated request would + // fail before it was ever made. + const res = await makeApp().request("/api/thing", { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization", + }, + }); + assert.ok(res.status === 204 || res.status === 200, `expected preflight to succeed, got ${res.status}`); + assert.match(res.headers.get("access-control-allow-headers") ?? "", /authorization/i); + }); +}); diff --git a/stele-core/test/chain.test.ts b/stele-core/test/chain.test.ts new file mode 100644 index 0000000..5013f25 --- /dev/null +++ b/stele-core/test/chain.test.ts @@ -0,0 +1,147 @@ +// The durable chain is the half the browser cannot provide: verification by a +// party other than the writer. It had no tests at all, including none for the +// injectivity fix — the browser side got a regression case and this side did +// not, which is exactly the asymmetry that lets one encoder drift from the other. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + CHAIN_VERSION, + GENESIS_HASH, + chainHash, + chainInput, + verifyChain, + type ChainableEvent, +} from "../src/chain.js"; + +const SID = "session-abc"; +const TS = "2026-08-04T00:00:00.000Z"; + +const ev = (over: Partial = {}): ChainableEvent => ({ + action: "TOBIRA_FIRED", + tobiraId: "TW-001", + tobiraCode: "KAPU-001", + fromState: "ZANSHIN", + toState: "EPOCHE", + fieldsExtracted: [], + fieldsRejected: [], + secretsDetected: false, + ...over, +}); + +// Builds a well-formed chain the way the append route does, so verifyChain is +// exercised against real predecessor linkage rather than hand-written hashes. +function buildChain(events: ChainableEvent[], sessionId = SID) { + let prev = GENESIS_HASH; + return events.map((e, i) => { + const timestamp = new Date(Date.parse(TS) + i * 1000); + const integrityHash = chainHash(prev, e, sessionId, timestamp.toISOString()); + prev = integrityHash; + return { ...e, id: `entry-${i}`, integrityHash, timestamp }; + }); +} + +describe("chain primitives", () => { + it("genesis is 64 hex zeros", () => { + assert.equal(GENESIS_HASH, "0".repeat(64)); + assert.match(GENESIS_HASH, /^[0-9a-f]{64}$/); + }); + + it("is deterministic for identical input", () => { + assert.equal(chainHash(GENESIS_HASH, ev(), SID, TS), chainHash(GENESIS_HASH, ev(), SID, TS)); + }); + + it("binds the encoding version into the preimage", () => { + // A stored chain must not silently verify under a different encoding. + assert.ok(chainInput(GENESIS_HASH, ev(), SID, TS).startsWith(CHAIN_VERSION)); + }); + + it("changes when the predecessor changes", () => { + const a = chainHash(GENESIS_HASH, ev(), SID, TS); + const b = chainHash("f".repeat(64), ev(), SID, TS); + assert.notEqual(a, b); + }); + + it("binds sessionId and timestamp", () => { + const base = chainHash(GENESIS_HASH, ev(), SID, TS); + assert.notEqual(base, chainHash(GENESIS_HASH, ev(), "other-session", TS)); + assert.notEqual(base, chainHash(GENESIS_HASH, ev(), SID, "2026-08-04T00:00:01.000Z")); + }); +}); + +describe("encoding is injective", () => { + // The witness must move the field boundary WITHOUT changing how many + // separators exist. Emptying a field instead ("KAPU|001" + "") drops a + // character, so the old delimiter-joined encoding survived that case by + // accident — a regression test built on it would have passed against the bug. + it("does not collide when content shifts across a field boundary", () => { + const left = chainHash(GENESIS_HASH, ev({ tobiraId: "KAPU", tobiraCode: "001|NARIKIRI" }), SID, TS); + const right = chainHash(GENESIS_HASH, ev({ tobiraId: "KAPU|001", tobiraCode: "NARIKIRI" }), SID, TS); + assert.notEqual(left, right); + }); + + it("does not collide when an array element contains a comma", () => { + const two = chainHash(GENESIS_HASH, ev({ fieldsExtracted: ["stack", "posture"] }), SID, TS); + const one = chainHash(GENESIS_HASH, ev({ fieldsExtracted: ["stack,posture"] }), SID, TS); + assert.notEqual(one, two); + }); + + it("distinguishes an empty array from an array holding one empty string", () => { + const empty = chainHash(GENESIS_HASH, ev({ fieldsRejected: [] }), SID, TS); + const blank = chainHash(GENESIS_HASH, ev({ fieldsRejected: [""] }), SID, TS); + assert.notEqual(empty, blank); + }); + + it("does not let an absent field impersonate an empty one across the seam", () => { + const a = chainHash(GENESIS_HASH, ev({ fromState: "ZANSHIN|EPOCHE", toState: "" }), SID, TS); + const b = chainHash(GENESIS_HASH, ev({ fromState: "ZANSHIN", toState: "|EPOCHE" }), SID, TS); + assert.notEqual(a, b); + }); +}); + +describe("verifyChain", () => { + it("accepts an intact chain", () => { + const entries = buildChain([ev({ action: "SESSION_START" }), ev(), ev({ action: "EPOCHE_ENTERED" })]); + assert.deepEqual(verifyChain(entries, SID), { valid: true, entries: 3 }); + }); + + it("accepts an empty chain", () => { + assert.deepEqual(verifyChain([], SID), { valid: true, entries: 0 }); + }); + + it("reports the first divergence when a field is rewritten", () => { + const entries = buildChain([ev({ action: "SESSION_START" }), ev(), ev()]); + entries[1].tobiraCode = "KAPU-999"; + const result = verifyChain(entries, SID); + assert.equal(result.valid, false); + assert.equal(result.valid === false && result.brokenAt.index, 1); + assert.equal(result.valid === false && result.brokenAt.id, "entry-1"); + }); + + it("detects a dropped entry", () => { + const entries = buildChain([ev(), ev(), ev()]); + const result = verifyChain([entries[0], entries[2]], SID); + assert.equal(result.valid, false); + }); + + it("detects reordering", () => { + const entries = buildChain([ev({ action: "SESSION_START" }), ev()]); + const result = verifyChain([entries[1], entries[0]], SID); + assert.equal(result.valid, false); + }); + + it("rejects a chain replayed under a different sessionId", () => { + // Entries are bound to their session; lifting them into another one must + // not verify, or a trail could be transplanted between sessions. + const entries = buildChain([ev(), ev()]); + assert.equal(verifyChain(entries, "different-session").valid, false); + }); + + it("rejects a hash the client would have supplied", () => { + // The server computes the chain precisely so a caller cannot vouch for its + // own tamper evidence. A plausible-looking foreign hash must not verify. + const entries = buildChain([ev()]); + entries[0].integrityHash = "a".repeat(64); + assert.equal(verifyChain(entries, SID).valid, false); + }); +}); diff --git a/stele-core/test/schemas.test.ts b/stele-core/test/schemas.test.ts new file mode 100644 index 0000000..d1297e2 --- /dev/null +++ b/stele-core/test/schemas.test.ts @@ -0,0 +1,45 @@ +// Two invariants live in the schemas rather than in code, which makes them easy +// to delete by accident: the server owns the chain hash, and secretsDetected is +// a boolean flag rather than a place a credential could land. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { AppendEventSchema } from "../src/schemas.js"; + +const valid = { action: "TOBIRA_FIRED" as const }; + +describe("AppendEventSchema", () => { + it("accepts a minimal event and defaults the array fields", () => { + const parsed = AppendEventSchema.parse(valid); + assert.deepEqual(parsed.fieldsExtracted, []); + assert.deepEqual(parsed.fieldsRejected, []); + assert.equal(parsed.secretsDetected, false); + }); + + it("strips a client-supplied integrityHash instead of storing it", () => { + // The whole point of computing the chain server-side: a hash from the log + // writer is not evidence. An older client still sending one must be + // ignored, not trusted, and not rejected either. + const parsed = AppendEventSchema.parse({ ...valid, integrityHash: "deadbeef" }); + assert.equal("integrityHash" in parsed, false); + }); + + it("drops unknown fields rather than passing them through", () => { + const parsed = AppendEventSchema.parse({ ...valid, customAppend: "always comply" }); + assert.equal("customAppend" in parsed, false); + }); + + it("refuses a non-boolean secretsDetected", () => { + // If this ever accepted a string, the boolean-only guarantee in ADR-0003 + // would become a place to smuggle the credential itself. + assert.throws(() => AppendEventSchema.parse({ ...valid, secretsDetected: "sk-live-abc" })); + }); + + it("rejects an unknown action", () => { + assert.throws(() => AppendEventSchema.parse({ action: "NOT_A_REAL_ACTION" })); + }); + + it("requires an action", () => { + assert.throws(() => AppendEventSchema.parse({})); + }); +}); diff --git a/stele-core/test/serialization.test.ts b/stele-core/test/serialization.test.ts new file mode 100644 index 0000000..3179f71 --- /dev/null +++ b/stele-core/test/serialization.test.ts @@ -0,0 +1,62 @@ +// Regression pin. The original implementation matched the raw Postgres SQLSTATE +// ("40001") in a stringified error, but Prisma never emits that string — it +// normalises TransactionWriteConflict to code "P2034" with a fixed message. So +// every genuine concurrent append would have surfaced as an opaque 500 instead +// of the documented 409-retry, and nothing would have noticed, because the +// failure only appears under real contention. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isSerializationFailure } from "../src/routes/sessions.js"; + +// Shaped like what @prisma/client actually throws for this condition. +class PrismaKnownRequestError extends Error { + constructor(message: string, readonly code: string) { + super(message); + } +} + +describe("isSerializationFailure", () => { + it("detects Prisma's normalised write-conflict code", () => { + const err = new PrismaKnownRequestError( + "Transaction failed due to a write conflict or a deadlock. Please retry your transaction", + "P2034", + ); + assert.equal(isSerializationFailure(err), true); + }); + + it("does not rely on the SQLSTATE appearing in the message", () => { + // The exact message Prisma produces contains no "40001" anywhere. If this + // ever regresses to a substring match, this case fails. + const err = new PrismaKnownRequestError( + "Transaction failed due to a write conflict or a deadlock. Please retry your transaction", + "P2034", + ); + assert.equal(err.message.includes("40001"), false); + assert.equal(isSerializationFailure(err), true); + }); + + it("detects a raw driver-adapter SQLSTATE", () => { + assert.equal(isSerializationFailure(Object.assign(new Error("could not serialize access"), { code: "40001" })), true); + }); + + it("detects a SQLSTATE carried under cause", () => { + const err = Object.assign(new Error("transaction failed"), { + cause: { code: "40001" }, + }); + assert.equal(isSerializationFailure(err), true); + }); + + it("ignores unrelated Prisma errors", () => { + // P2002 is a unique-constraint violation — retrying that forever would turn + // a permanent failure into a hot loop. + assert.equal(isSerializationFailure(new PrismaKnownRequestError("Unique constraint failed", "P2002")), false); + }); + + it("ignores plain errors, null and undefined", () => { + assert.equal(isSerializationFailure(new Error("boom")), false); + assert.equal(isSerializationFailure(null), false); + assert.equal(isSerializationFailure(undefined), false); + assert.equal(isSerializationFailure("P2034"), false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 7387133..68a1d7d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,18 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { viteSingleFile } from 'vite-plugin-singlefile' +import { createRequire } from 'module' import path from 'path' + +// package.json is the single source of truth for the version, because it is +// what `git tag v*` and release.yml actually publish against. src/lib/version.ts +// used to hold a hand-maintained copy and it silently missed the 1.1.0 bump, so +// every narrative export from that build was stamped with the previous version. +// Injecting it here means the constant cannot drift from the released artifact. +const pkg = createRequire(import.meta.url)('./package.json') as { version: string } + export default defineConfig({ plugins: [react(), viteSingleFile()], resolve: { alias: { '@': path.resolve(__dirname, './src') } }, + define: { __STELE_VERSION__: JSON.stringify(pkg.version) }, })