Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
30
api/src/middleware/access-log.ts
Normal file
30
api/src/middleware/access-log.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
/**
|
||||
* Structured access logger. Outputs one JSON line per request to stdout
|
||||
* prefixed with [ACCESS] for easy parsing by fail2ban and log aggregation.
|
||||
*/
|
||||
export function accessLog(req: Request, res: Response, next: NextFunction): void {
|
||||
const start = Date.now();
|
||||
|
||||
res.on("finish", () => {
|
||||
const ms = Date.now() - start;
|
||||
const log = {
|
||||
ts: new Date().toISOString(),
|
||||
ip: (req as any).clientIp || req.ip || "-",
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
ms,
|
||||
ua: req.headers["user-agent"]?.slice(0, 200) || "-",
|
||||
bytes: parseInt(res.getHeader("content-length") as string, 10) || 0,
|
||||
};
|
||||
|
||||
// Only log non-health requests (reduces noise)
|
||||
if (req.originalUrl !== "/api/v1/status") {
|
||||
console.log(`[ACCESS] ${JSON.stringify(log)}`);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
41
api/src/middleware/admin-auth.ts
Normal file
41
api/src/middleware/admin-auth.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const JWT_SECRET = process.env.ADMIN_JWT_SECRET || "change-this-in-production";
|
||||
|
||||
export interface AdminPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
admin?: AdminPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign a JWT for an admin user. */
|
||||
export function signAdminToken(payload: AdminPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: "8h" });
|
||||
}
|
||||
|
||||
/** Verify admin JWT from Authorization: Bearer <token> header. */
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Authentication required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as AdminPayload;
|
||||
req.admin = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: "Invalid or expired token." });
|
||||
}
|
||||
}
|
||||
41
api/src/middleware/cors.ts
Normal file
41
api/src/middleware/cors.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import cors from "cors";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const PRODUCTION_ORIGINS = [
|
||||
"https://performancewest.net",
|
||||
"https://www.performancewest.net",
|
||||
"https://dev.performancewest.net",
|
||||
"http://192.168.7.4:4322",
|
||||
];
|
||||
|
||||
const DEV_ORIGINS = [
|
||||
"http://localhost:4322",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:4322",
|
||||
"http://127.0.0.1:3001",
|
||||
];
|
||||
|
||||
// In dev mode, also allow any origin on common dev ports (LAN access)
|
||||
const isDev = config.nodeEnv !== "production";
|
||||
|
||||
const allowedOrigins =
|
||||
config.nodeEnv === "production"
|
||||
? PRODUCTION_ORIGINS
|
||||
: [...PRODUCTION_ORIGINS, ...DEV_ORIGINS];
|
||||
|
||||
export const corsMiddleware = cors({
|
||||
origin: (origin, cb) => {
|
||||
// Allow requests with no origin (server-to-server, curl, etc.)
|
||||
if (!origin) { cb(null, true); return; }
|
||||
if (allowedOrigins.includes(origin)) { cb(null, true); return; }
|
||||
// In dev mode, allow any origin on known dev ports (LAN access from other machines)
|
||||
if (isDev && /^http:\/\/[\d.]+:(4322|3001)$/.test(origin)) { cb(null, true); return; }
|
||||
if (isDev && /^http:\/\/192\.168\./.test(origin)) { cb(null, true); return; }
|
||||
cb(new Error(`Origin ${origin} not allowed by CORS`));
|
||||
},
|
||||
methods: ["GET", "POST", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
exposedHeaders: ["RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset"],
|
||||
credentials: true,
|
||||
maxAge: 86_400,
|
||||
});
|
||||
57
api/src/middleware/customer-auth.ts
Normal file
57
api/src/middleware/customer-auth.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const JWT_SECRET = process.env.ADMIN_JWT_SECRET || "changeme";
|
||||
const COOKIE_NAME = "pw_customer";
|
||||
|
||||
export interface CustomerPayload {
|
||||
customerId: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
customer?: CustomerPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Middleware: attach customer from cookie JWT. Never blocks — sets req.customer if valid. */
|
||||
export function optionalCustomerAuth(req: Request, _res: Response, next: NextFunction) {
|
||||
const token = req.cookies?.[COOKIE_NAME];
|
||||
if (!token) return next();
|
||||
try {
|
||||
req.customer = jwt.verify(token, JWT_SECRET) as CustomerPayload;
|
||||
} catch {
|
||||
// expired or invalid — ignore, let route decide
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Middleware: require valid customer session. Returns 401 if not logged in. */
|
||||
export function requireCustomerAuth(req: Request, res: Response, next: NextFunction) {
|
||||
optionalCustomerAuth(req, res, () => {
|
||||
if (!req.customer) {
|
||||
return res.status(401).json({ error: "Login required", code: "UNAUTHENTICATED" });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/** Issue a customer session JWT cookie (7-day). */
|
||||
export function issueCustomerCookie(res: Response, payload: CustomerPayload) {
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" });
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the customer session cookie. */
|
||||
export function clearCustomerCookie(res: Response) {
|
||||
res.clearCookie(COOKIE_NAME, { path: "/" });
|
||||
}
|
||||
33
api/src/middleware/error-handler.ts
Normal file
33
api/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
interface AppError extends Error {
|
||||
statusCode?: number;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/** Create a typed error with status code. */
|
||||
export function createError(message: string, statusCode: number, details?: unknown): AppError {
|
||||
const err: AppError = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
err.details = details;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Express error-handling middleware — must be mounted last. */
|
||||
export function errorHandler(
|
||||
err: AppError,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
const status = err.statusCode || 500;
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
console.error(`[ERROR] ${status} ${err.message}`, isDev ? err.stack : "");
|
||||
|
||||
res.status(status).json({
|
||||
error: err.message || "Internal server error",
|
||||
...(err.details ? { details: err.details } : {}),
|
||||
...(isDev && err.stack ? { stack: err.stack } : {}),
|
||||
});
|
||||
}
|
||||
27
api/src/middleware/internal-auth.ts
Normal file
27
api/src/middleware/internal-auth.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// internal-auth.ts — Shared-secret authentication for internal API endpoints
|
||||
// Used by Verilex Data to access bulk entity export and name search endpoints.
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const INTERNAL_API_KEY = process.env.PW_INTERNAL_API_KEY || "";
|
||||
|
||||
export function internalAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!INTERNAL_API_KEY) {
|
||||
res.status(503).json({ error: "Internal API not configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization || "";
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Missing Authorization header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
if (token !== INTERNAL_API_KEY) {
|
||||
res.status(401).json({ error: "Invalid API key" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
107
api/src/middleware/portalAuth.ts
Normal file
107
api/src/middleware/portalAuth.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Portal authentication middleware.
|
||||
*
|
||||
* Customer portal pages (/portal/*) are accessed via signed JWT links that
|
||||
* are emailed to customers. No password is needed — the link IS the credential.
|
||||
*
|
||||
* Token format (JWT, HS256):
|
||||
* payload: { order_id, order_type, email, iat, exp }
|
||||
* secret: CUSTOMER_JWT_SECRET env var
|
||||
*
|
||||
* Token is passed as:
|
||||
* 1. Query param: ?token=... (email links)
|
||||
* 2. Authorization header: Bearer ... (XHR/fetch from portal page)
|
||||
* 3. Cookie: pw_portal_token=... (set by the portal page on first load)
|
||||
*
|
||||
* Helper: generatePortalToken(order_id, order_type, email) → signed JWT
|
||||
*/
|
||||
|
||||
import { type Request, type Response, type NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const CUSTOMER_JWT_SECRET = process.env.CUSTOMER_JWT_SECRET || "changeme_long_random_string";
|
||||
const TOKEN_TTL_SECONDS = 72 * 60 * 60; // 72 hours
|
||||
|
||||
export interface PortalTokenPayload {
|
||||
order_id: string;
|
||||
order_type: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ─── Generate a signed portal link token ─────────────────────────────────────
|
||||
|
||||
export function generatePortalToken(
|
||||
order_id: string,
|
||||
order_type: string,
|
||||
email: string,
|
||||
): string {
|
||||
return jwt.sign(
|
||||
{ order_id, order_type, email } satisfies PortalTokenPayload,
|
||||
CUSTOMER_JWT_SECRET,
|
||||
{ expiresIn: TOKEN_TTL_SECONDS },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Build a signed portal URL ────────────────────────────────────────────────
|
||||
|
||||
export function portalUrl(
|
||||
path: string, // e.g. "/portal/domain-search"
|
||||
order_id: string,
|
||||
order_type: string,
|
||||
email: string,
|
||||
): string {
|
||||
const token = generatePortalToken(order_id, order_type, email);
|
||||
const domain = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "http://localhost:4321";
|
||||
return `${domain}${path}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
// ─── Middleware: verify portal token ─────────────────────────────────────────
|
||||
// Attaches req.portalAuth = { order_id, order_type, email } on success.
|
||||
// Returns 401 if token is missing, 403 if invalid/expired.
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
portalAuth?: PortalTokenPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requirePortalAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
// 1. Query param (email link on first load)
|
||||
let rawToken = (req.query.token as string) || null;
|
||||
|
||||
// 2. Authorization header (XHR from portal page after first load)
|
||||
if (!rawToken) {
|
||||
const authHeader = req.headers.authorization || "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
rawToken = authHeader.slice(7);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cookie (set by portal page JS after extracting from URL)
|
||||
if (!rawToken) {
|
||||
rawToken = (req.cookies?.pw_portal_token as string) || null;
|
||||
}
|
||||
|
||||
if (!rawToken) {
|
||||
res.status(401).json({ error: "Authentication required. Please use the link from your email.", code: "AUTH_REQUIRED" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(rawToken, CUSTOMER_JWT_SECRET) as PortalTokenPayload & { iat: number; exp: number };
|
||||
req.portalAuth = {
|
||||
order_id: payload.order_id,
|
||||
order_type: payload.order_type,
|
||||
email: payload.email,
|
||||
};
|
||||
next();
|
||||
} catch (err: any) {
|
||||
if (err?.name === "TokenExpiredError") {
|
||||
res.status(403).json({ error: "Your portal link has expired. Please request a new one.", code: "TOKEN_EXPIRED" });
|
||||
} else {
|
||||
res.status(403).json({ error: "Invalid portal link.", code: "TOKEN_INVALID" });
|
||||
}
|
||||
}
|
||||
}
|
||||
21
api/src/middleware/rate-limit.ts
Normal file
21
api/src/middleware/rate-limit.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import rateLimit from "express-rate-limit";
|
||||
|
||||
/** Global rate limiter — 200 requests per minute per IP. */
|
||||
export const globalLimiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => (req as any).clientIp || req.ip || "unknown",
|
||||
message: { error: "Too many requests. Please wait and try again." },
|
||||
});
|
||||
|
||||
/** Strict limiter for form submissions — 5 per minute per IP (50 in dev/test). */
|
||||
export const submitLimiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: process.env.NODE_ENV === "production" ? 5 : 50,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => (req as any).clientIp || req.ip || "unknown",
|
||||
message: { error: "Too many submissions. Please wait a moment." },
|
||||
});
|
||||
38
api/src/middleware/security.ts
Normal file
38
api/src/middleware/security.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import helmet from "helmet";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
// Strict security headers via Helmet.
|
||||
export const securityHeaders = helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
|
||||
frameguard: { action: "deny" },
|
||||
noSniff: true,
|
||||
referrerPolicy: { policy: "no-referrer" },
|
||||
hidePoweredBy: true,
|
||||
// This is a public API accessed cross-origin — must be cross-origin not same-origin
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
// Allow cross-origin opener for Stripe Identity redirect flows
|
||||
crossOriginOpenerPolicy: { policy: "unsafe-none" },
|
||||
});
|
||||
|
||||
// Attach normalised client IP to req (handles IPv6-mapped IPv4).
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
clientIp?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractClientIp(req: Request, _res: Response, next: NextFunction): void {
|
||||
let ip = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "";
|
||||
// Normalise ::ffff:127.0.0.1 → 127.0.0.1
|
||||
if (ip.startsWith("::ffff:")) ip = ip.slice(7);
|
||||
req.clientIp = ip;
|
||||
next();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue