Fail the gate on a first-party /api path

Claude-Session: https://claude.ai/code/session_01TUxvfuta1Z44KVNYuAz71F
This commit is contained in:
2026-09-24 14:52:08 -07:00
parent d3e091aa97
commit c70facdadc
3 changed files with 54 additions and 1 deletions
+5 -1
View File
@@ -9,7 +9,10 @@
# build of @hanzo/esign-remix, which type-checks every workspace package it
# depends on. A type error anywhere under packages/ fails here.
#
# `lint:tx` runs first because it takes seconds and answers a question the build
# `lint:paths` runs before install: it needs no dependencies and fails the gate
# the moment a first-party /api path is served or called (every route is /v1).
#
# `lint:tx` runs next because it takes seconds and answers a question the build
# cannot: whether anything inside a `$transaction` reaches the global Prisma
# client. Base SQLite serves one write connection, so such a call waits on a
# lock the transaction holds and the transaction dies (`P2028`) — at runtime,
@@ -18,6 +21,7 @@ test:
- name: build
run: |
set -euo pipefail
node scripts/lint-paths.mjs
npm ci --no-audit --no-fund
npm run lint:tx
npx turbo run build --filter=@hanzo/esign-remix
+1
View File
@@ -15,6 +15,7 @@
"start": "turbo run start --filter=@hanzo/esign-remix --filter=@hanzo/esign-documentation --filter=@hanzo/esign-openpage-api",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"lint:paths": "node scripts/lint-paths.mjs",
"lint:tx": "npm test -w @hanzo/esign-eslint-config && eslint --no-config-lookup --no-inline-config -c packages/eslint-config/transaction.cjs packages apps",
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
"prepare": "husky && husky install || true",
+48
View File
@@ -0,0 +1,48 @@
/**
* Every route this app serves or calls is under /v1. Fails when a tracked file
* serves or calls a first-party /api path again.
*
* A path that starts right after a quote, backtick or paren is relative, so it
* is ours; an absolute URL is ours when its host is. Third-party hosts
* (api.github.com, a vendor's /api/stats) name a different host and do not
* match. A route directory named api is a served /api prefix with no string to
* find, so the tree is checked too.
*
* node scripts/lint-paths.mjs
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const relative = /['"`(]\/api(\/|['"`)])/;
const ours = /\b(?:e?sign\.hanzo\.ai|localhost(?::\d+)?|127\.0\.0\.1(?::\d+)?)\/api\//;
const routeDir = /(^|\/)(routes\/api\+|app\/api)\//;
const text = /\.(c?js|mjs|ts|tsx|json|ya?ml|sh|mdx?|html|toml)$/;
const files = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).split('\n').filter(Boolean);
const hits = [];
for (const file of files) {
if (routeDir.test(file)) {
hits.push(`${file}: serves /api from its directory`);
}
if (!text.test(file) || file.endsWith('package-lock.json')) {
continue;
}
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
if (relative.test(line) || ours.test(line)) {
hits.push(`${file}:${i + 1}: ${line.trim()}`);
}
});
}
if (hits.length > 0) {
console.error(`first-party /api paths (every route is /v1):\n${hits.join('\n')}`);
process.exit(1);
}
console.log(`lint:paths: ${files.length} files, no first-party /api path`);