mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +00:00
Migrate logicsrc-web from Vite SPA to Next.js 16 App Router
Replaces the Vite single-page app + custom Node server.js with a Next.js 16.2.6 App Router app. - proxy.ts (src/proxy.ts): www.logicsrc.com -> logicsrc.com 301 over https, preserving path + query (the original request, now via Next 16 Proxy). - One SSR page via an optional catch-all ([[...slug]]) that renders the same marketing/spec page for each known top-level route (/docs, /blog, /openspec, ...) and 404s unknown paths, preserving existing canonical URLs. Markup is a faithful server-rendered port of the old main.ts (SEO upgrade over the prior client render); interactivity (hire-us form, CoinPay button, section scroll) moves to a client component. - API routes ported to app/api/**: hire-us coinpay-checkout + project-request, oauth/coinpay start/callback/session, webhooks/coinpay. Shared logic in src/lib/coinpay.ts (eligibility, payment-rail selection, webhook verify, HMAC session sign/verify, cookies). - commandboard-api (/health + /api/boards|tasks|plugins/*) is no longer mounted in-process; next.config.ts proxies those paths to COMMANDBOARD_API_URL via afterFiles rewrites (our own /api routes match first). - Build/start switch to next build / next start. Contract tests rewritten to exercise proxy.ts, the route handlers, and pure helpers directly (21 passing); Playwright webServer updated. Deployment (Railway): set COMMANDBOARD_API_URL to the commandboard-api service URL and run it as its own service; root start now runs next start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cf5d526176
commit
f0a9f3890e
24 changed files with 2102 additions and 1394 deletions
197
apps/logicsrc-web/src/lib/coinpay.ts
Normal file
197
apps/logicsrc-web/src/lib/coinpay.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
// Server-side CoinPay + session helpers, ported from the legacy server.js so the
|
||||
// Next API routes keep identical behavior (eligibility-driven payment rails,
|
||||
// signed webhook verification, and HMAC-signed OAuth session cookies).
|
||||
|
||||
export const COINPAY_OAUTH_STATE_COOKIE = "logicsrc_coinpay_oauth_state";
|
||||
export const COINPAY_SESSION_COOKIE = "logicsrc_coinpay_session";
|
||||
|
||||
export interface MerchantEligibility {
|
||||
accepts_card: boolean;
|
||||
accepts_crypto: boolean;
|
||||
chains: string[];
|
||||
}
|
||||
|
||||
export interface PaymentRail {
|
||||
method: "both" | "card" | "crypto";
|
||||
currency: string;
|
||||
blockchain: string | null;
|
||||
}
|
||||
|
||||
export interface CoinPayOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
issuer: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
export function parseJson(text: string): Record<string, unknown> {
|
||||
try {
|
||||
return text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMerchantEligibility(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
merchantId: string | undefined
|
||||
): Promise<MerchantEligibility | null> {
|
||||
if (!merchantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("/api/payments/merchant-eligibility", apiUrl);
|
||||
url.searchParams.set("merchant_id", merchantId);
|
||||
const response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${apiKey ?? ""}` }
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJson(text);
|
||||
|
||||
if (!response.ok || payload.success !== true) {
|
||||
console.warn("[coinpay] merchant eligibility unavailable", {
|
||||
status: response.status,
|
||||
error: (payload.error as string) || text.slice(0, 120)
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accepts_card: payload.accepts_card === true,
|
||||
accepts_crypto: payload.accepts_crypto === true,
|
||||
chains: Array.isArray(payload.chains)
|
||||
? (payload.chains as unknown[]).filter((chain): chain is string => typeof chain === "string")
|
||||
: []
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[coinpay] merchant eligibility request failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function choosePaymentRail(
|
||||
eligibility: MerchantEligibility | null,
|
||||
configuredBlockchain: string
|
||||
): PaymentRail | null {
|
||||
const cryptoCurrency = configuredBlockchain.toLowerCase();
|
||||
|
||||
if (!eligibility) {
|
||||
return { method: "both", currency: cryptoCurrency, blockchain: configuredBlockchain };
|
||||
}
|
||||
|
||||
const configuredChainAvailable = eligibility.chains
|
||||
.map((chain) => chain.toUpperCase())
|
||||
.includes(configuredBlockchain.toUpperCase());
|
||||
const acceptsConfiguredCrypto = eligibility.accepts_crypto && configuredChainAvailable;
|
||||
|
||||
if (eligibility.accepts_card && acceptsConfiguredCrypto) {
|
||||
return { method: "both", currency: cryptoCurrency, blockchain: configuredBlockchain };
|
||||
}
|
||||
|
||||
if (eligibility.accepts_card) {
|
||||
return { method: "card", currency: "card", blockchain: null };
|
||||
}
|
||||
|
||||
if (acceptsConfiguredCrypto) {
|
||||
return { method: "crypto", currency: cryptoCurrency, blockchain: configuredBlockchain };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function verifyCoinPayWebhook(
|
||||
rawBody: string,
|
||||
signatureHeader: string | null | undefined,
|
||||
secret: string
|
||||
): boolean {
|
||||
if (!signatureHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = signatureHeader.split(",");
|
||||
const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
|
||||
const signature = parts.find((part) => part.startsWith("v1="))?.slice(3);
|
||||
if (!timestamp || !signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestampSeconds = Number.parseInt(timestamp, 10);
|
||||
if (!Number.isFinite(timestampSeconds) || Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
||||
const actualBuffer = Buffer.from(signature, "hex");
|
||||
const expectedBuffer = Buffer.from(expected, "hex");
|
||||
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCoinPayOAuthConfig(): CoinPayOAuthConfig | null {
|
||||
const clientId = process.env.COINPAY_OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.COINPAY_OAUTH_CLIENT_SECRET;
|
||||
const redirectUri = process.env.COINPAY_OAUTH_REDIRECT_URI;
|
||||
const issuer = process.env.COINPAY_OAUTH_ISSUER || process.env.COINPAY_API_URL || "https://coinpayportal.com";
|
||||
const scopes = process.env.COINPAY_OAUTH_SCOPES || "openid profile email";
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { clientId, clientSecret, redirectUri, issuer, scopes };
|
||||
}
|
||||
|
||||
function getSessionSecret(): string {
|
||||
return process.env.LOGICSRC_SESSION_SECRET || process.env.COINPAY_OAUTH_CLIENT_SECRET || "logicsrc-dev-session-secret";
|
||||
}
|
||||
|
||||
export function signSession(payload: Record<string, unknown>): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signature = createHmac("sha256", getSessionSecret()).update(encoded).digest("base64url");
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifySession(value: string): Record<string, unknown> | null {
|
||||
const [encoded, signature] = value.split(".");
|
||||
if (!encoded || !signature) return null;
|
||||
|
||||
const expected = createHmac("sha256", getSessionSecret()).update(encoded).digest("base64url");
|
||||
const actualBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CookieOptions {
|
||||
maxAge?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "SameSite=Lax", `Path=${options.path || "/"}`];
|
||||
|
||||
if (typeof options.maxAge === "number") {
|
||||
parts.push(`Max-Age=${options.maxAge}`);
|
||||
}
|
||||
|
||||
if ((process.env.PUBLIC_URL || "").startsWith("https://")) {
|
||||
parts.push("Secure");
|
||||
}
|
||||
|
||||
return parts.join("; ");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue