feat: complete Epic 1 production foundation
This commit is contained in:
@@ -29,6 +29,7 @@ const BUNDLES = {
|
||||
'js/api.js',
|
||||
'js/ui-feedback.js',
|
||||
'js/auth.js',
|
||||
'js/onboarding.js',
|
||||
],
|
||||
footer: 'window.CrankAuth.guardProtectedPage();\n',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const http = require('http');
|
||||
|
||||
const PORT = Number(process.env.CRANK_E2E_STREAM_FIXTURE_PORT || 3310);
|
||||
|
||||
function send(response, status, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, `http://127.0.0.1:${PORT}`);
|
||||
if (request.method !== 'GET') {
|
||||
send(response, 405, { error: 'method_not_allowed' });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/health') {
|
||||
send(response, 200, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/rates') {
|
||||
send(response, 200, {
|
||||
amount: 1,
|
||||
base: url.searchParams.get('base') || 'USD',
|
||||
date: '2026-08-24',
|
||||
rates: { EUR: 0.91 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/upstream-error') {
|
||||
send(response, 503, { error: 'fixture_upstream_unavailable' });
|
||||
return;
|
||||
}
|
||||
send(response, 404, { error: 'fixture_not_found' });
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Playwright HTTP fixture listening on http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
@@ -59,12 +59,17 @@ cleanup() {
|
||||
kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true
|
||||
rm -f "$TMP_DIR/ui-server.pid"
|
||||
fi
|
||||
if [[ -f "$TMP_DIR/http-fixture.pid" ]]; then
|
||||
kill "$(cat "$TMP_DIR/http-fixture.pid")" >/dev/null 2>&1 || true
|
||||
rm -f "$TMP_DIR/http-fixture.pid"
|
||||
fi
|
||||
if [[ "$USE_EXTERNAL_POSTGRES" != "1" ]]; then
|
||||
docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
kill_port_processes "$UI_PORT"
|
||||
kill_port_processes "$ADMIN_PORT"
|
||||
kill_port_processes "$MCP_PORT"
|
||||
kill_port_processes "$STREAM_FIXTURE_PORT"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
@@ -118,27 +123,62 @@ export CRANK_STORAGE_ROOT="$TMP_DIR/storage"
|
||||
export CRANK_ADMIN_BIND="0.0.0.0:$ADMIN_PORT"
|
||||
export CRANK_MCP_BIND="0.0.0.0:$MCP_PORT"
|
||||
export CRANK_MCP_REFRESH_MS="1000"
|
||||
export CRANK_OUTBOUND_ALLOWED_HOSTS="127.0.0.1"
|
||||
export CRANK_LOG_LEVEL="info"
|
||||
export CRANK_SESSION_SECRET="e2e-session-secret"
|
||||
export CRANK_PASSWORD_PEPPER="e2e-password-pepper"
|
||||
export CRANK_MASTER_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
export CRANK_SESSION_TTL_HOURS="24"
|
||||
export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL"
|
||||
export CRANK_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASSWORD"
|
||||
export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
|
||||
export CRANK_DEMO_SEED="true"
|
||||
export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT"
|
||||
mkdir -p "$CRANK_STORAGE_ROOT"
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR/apps/ui"
|
||||
exec node scripts/playwright-http-fixture.js >"$LOG_DIR/http-fixture.log" 2>&1
|
||||
) &
|
||||
echo $! > "$TMP_DIR/http-fixture.pid"
|
||||
|
||||
until curl -fsS "http://127.0.0.1:$STREAM_FIXTURE_PORT/health" >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
cargo run -p admin-api --bin crank-migrate -- apply >"$LOG_DIR/migrate.log" 2>&1
|
||||
)
|
||||
|
||||
BOOTSTRAP_JSON="$(
|
||||
cd "$ROOT_DIR"
|
||||
cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-create \
|
||||
--email "$ADMIN_EMAIL" \
|
||||
--display-name "Crank E2E" \
|
||||
>"$LOG_DIR/bootstrap-create.log" 2>&1
|
||||
tail -n 1 "$LOG_DIR/bootstrap-create.log"
|
||||
)"
|
||||
BOOTSTRAP_TOKEN="$("$PYTHON_BIN" - <<'PY' "$BOOTSTRAP_JSON"
|
||||
import json, sys
|
||||
print(json.loads(sys.argv[1])["bootstrap_token"])
|
||||
PY
|
||||
)"
|
||||
printf '%s' "$BOOTSTRAP_TOKEN" >"$TMP_DIR/bootstrap-token.txt"
|
||||
printf '%s' "$ADMIN_PASSWORD" >"$TMP_DIR/admin-password.txt"
|
||||
printf '%s' "$CRANK_PASSWORD_PEPPER" >"$TMP_DIR/password-pepper.txt"
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS \
|
||||
cargo run -p admin-api --bin admin-api >"$LOG_DIR/admin-api.log" 2>&1
|
||||
cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-complete \
|
||||
--token-file "$TMP_DIR/bootstrap-token.txt" \
|
||||
--password-file "$TMP_DIR/admin-password.txt" \
|
||||
--password-pepper-file "$TMP_DIR/password-pepper.txt" \
|
||||
>"$LOG_DIR/bootstrap-complete.log" 2>&1
|
||||
)
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS CRANK_DEMO_SEED=true \
|
||||
cargo run -p admin-api --bin admin-api >>"$LOG_DIR/admin-api.log" 2>&1
|
||||
) &
|
||||
echo $! > "$TMP_DIR/admin-api.pid"
|
||||
|
||||
@@ -150,8 +190,7 @@ done
|
||||
cd "$ROOT_DIR"
|
||||
exec env -u CRANK_ADMIN_BIND -u CRANK_STORAGE_ROOT -u CRANK_SESSION_SECRET \
|
||||
-u CRANK_PASSWORD_PEPPER -u CRANK_SESSION_TTL_HOURS \
|
||||
-u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_PASSWORD \
|
||||
-u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \
|
||||
-u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \
|
||||
cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1
|
||||
) &
|
||||
echo $! > "$TMP_DIR/mcp-server.pid"
|
||||
|
||||
@@ -7,7 +7,9 @@ const { URL } = require('url');
|
||||
const ROOT_DIR = path.resolve(__dirname, '..', 'dist');
|
||||
const UI_PORT = Number(process.env.CRANK_E2E_UI_PORT || 3300);
|
||||
const ADMIN_PORT = Number(process.env.CRANK_E2E_ADMIN_PORT || 3301);
|
||||
const MCP_PORT = Number(process.env.CRANK_E2E_MCP_PORT || 3302);
|
||||
const ADMIN_BASE = new URL(`http://127.0.0.1:${ADMIN_PORT}`);
|
||||
const MCP_BASE = new URL(`http://127.0.0.1:${MCP_PORT}`);
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
@@ -91,6 +93,9 @@ function serveFile(filePath, response) {
|
||||
}
|
||||
|
||||
fs.stat(normalized, (error, stats) => {
|
||||
if (response.destroyed || response.writableEnded) {
|
||||
return;
|
||||
}
|
||||
if (error || !stats.isFile()) {
|
||||
sendNotFound(response);
|
||||
return;
|
||||
@@ -103,19 +108,32 @@ function serveFile(filePath, response) {
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
|
||||
pipeline(fs.createReadStream(normalized), response, () => {});
|
||||
if (response.destroyed || response.writableEnded) {
|
||||
return;
|
||||
}
|
||||
var fileStream = fs.createReadStream(normalized);
|
||||
fileStream.on('error', function() {
|
||||
if (!response.destroyed && !response.writableEnded) response.destroy();
|
||||
});
|
||||
response.on('close', function() {
|
||||
if (!fileStream.destroyed) fileStream.destroy();
|
||||
});
|
||||
try {
|
||||
fileStream.pipe(response);
|
||||
} catch (_error) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function proxyRequest(request, response) {
|
||||
const target = new URL(request.url, ADMIN_BASE);
|
||||
function proxyRequest(request, response, base, targetPath) {
|
||||
const target = new URL(targetPath || request.url, base);
|
||||
const proxy = http.request(
|
||||
target,
|
||||
{
|
||||
method: request.method,
|
||||
headers: {
|
||||
...request.headers,
|
||||
host: `127.0.0.1:${ADMIN_PORT}`,
|
||||
},
|
||||
},
|
||||
(proxyResponse) => {
|
||||
@@ -130,16 +148,25 @@ function proxyRequest(request, response) {
|
||||
response.on('close', function() {
|
||||
if (!proxyResponse.destroyed) proxyResponse.destroy();
|
||||
});
|
||||
proxyResponse.pipe(response);
|
||||
try {
|
||||
proxyResponse.pipe(response);
|
||||
} catch (_error) {
|
||||
proxyResponse.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
proxy.on('error', () => {
|
||||
if (response.destroyed || response.writableEnded) return;
|
||||
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Bad Gateway');
|
||||
});
|
||||
|
||||
pipeline(request, proxy, () => {});
|
||||
try {
|
||||
pipeline(request, proxy, () => {});
|
||||
} catch (_error) {
|
||||
proxy.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
@@ -189,7 +216,12 @@ const server = http.createServer((request, response) => {
|
||||
}
|
||||
|
||||
if (urlPath.startsWith('/api/auth/') || urlPath.startsWith('/api/admin/')) {
|
||||
proxyRequest(request, response);
|
||||
proxyRequest(request, response, ADMIN_BASE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath.startsWith('/mcp/')) {
|
||||
proxyRequest(request, response, MCP_BASE, request.url.slice('/mcp'.length));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user