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) }, })