diff --git a/apps/logicsrc-web/.gitignore b/apps/logicsrc-web/.gitignore new file mode 100644 index 0000000..bd19b7b --- /dev/null +++ b/apps/logicsrc-web/.gitignore @@ -0,0 +1,7 @@ +# Next.js +/.next/ +/out/ +next-env.d.ts + +# misc +*.tsbuildinfo diff --git a/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts index 001a1d7..ad12321 100644 --- a/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts +++ b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts @@ -1,78 +1,130 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createHmac } from "node:crypto"; -import { accessSync } from "node:fs"; -import { createServer, type Server as HttpServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { setTimeout as delay } from "node:timers/promises"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -let server: ChildProcessWithoutNullStreams; -const port = 4291; -const baseUrl = `http://127.0.0.1:${port}`; -let nextCheckoutPort = 4292; +import { proxy } from "../src/proxy"; +import { renderPageMarkup } from "@/lib/page-markup"; +import { choosePaymentRail, signSession, verifyCoinPayWebhook, verifySession } from "@/lib/coinpay"; +import { POST as coinpayCheckout } from "@/app/api/hire-us/coinpay-checkout/route"; +import { POST as projectRequest } from "@/app/api/hire-us/project-request/route"; +import { GET as oauthStart } from "@/app/api/oauth/coinpay/start/route"; +import { GET as oauthCallback } from "@/app/api/oauth/coinpay/callback/route"; +import { GET as oauthSession } from "@/app/api/oauth/coinpay/session/route"; +import { POST as coinpayWebhook } from "@/app/api/webhooks/coinpay/route"; -beforeAll(async () => { - accessSync(new URL("../dist/index.html", import.meta.url)); - server = spawn(process.execPath, ["server.js"], { - cwd: new URL("..", import.meta.url), - env: { - ...process.env, - PORT: String(port), - COINPAY_API_KEY: "", - COINPAY_API_URL: "https://coinpayportal.example" - } - }); +// CoinPay/OAuth/webhook env keys we clear between tests so each case controls +// exactly what is configured. +const COINPAY_ENV_KEYS = [ + "COINPAY_API_KEY", + "COINPAY_API_URL", + "COINPAY_ELIGIBILITY_API_KEY", + "COINPAY_AGENT_API_KEY", + "COINPAY_BUSINESS_ID", + "COINPAY_MERCHANT_ID", + "COINPAY_ELIGIBILITY_MERCHANT_ID", + "COINPAY_HIRE_US_BLOCKCHAIN", + "PUBLIC_URL", + "COINPAY_WEBHOOK_SECRET", + "COINPAY_OAUTH_ISSUER", + "COINPAY_OAUTH_CLIENT_ID", + "COINPAY_OAUTH_CLIENT_SECRET", + "COINPAY_OAUTH_REDIRECT_URI", + "LOGICSRC_SESSION_SECRET" +]; - await waitForServer(baseUrl); +const savedEnv: Record = {}; + +beforeEach(() => { + for (const key of COINPAY_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } }); -afterAll(() => { - server?.kill(); +afterEach(() => { + for (const key of COINPAY_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + vi.restoreAllMocks(); }); -describe("LogicSRC web contracts", () => { - it("serves SPA routes from the built app shell", async () => { - for (const route of ["/", "/openspec", "/credential-sharing", "/docs", "/blog", "/hire-us", "/about", "/terms", "/privacy"]) { - const response = await fetch(`${baseUrl}${route}`); - const text = await response.text(); - - expect(response.status, route).toBe(200); - expect(response.headers.get("content-type")).toContain("text/html"); - expect(text).toContain('
'); - } +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } }); +} - it("serves sitemap.xml as XML with canonical routes", async () => { - const response = await fetch(`${baseUrl}/sitemap.xml`); - const text = await response.text(); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/xml"); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(text).toContain("https://logicsrc.com/openspec"); - expect(text).toContain("https://logicsrc.com/credential-sharing"); - expect(text).toContain("https://logicsrc.com/hire-us"); - expect(text).toContain("https://logicsrc.com/blog"); - }); - - it("serves blog/rss.xml as RSS XML", async () => { - const response = await fetch(`${baseUrl}/blog/rss.xml`); - const text = await response.text(); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/xml"); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(text).toContain("LogicSRC OpenSpec Compatibility"); - expect(text).toContain("LogicSRC Credential Sharing OpenSpec"); - }); - - it("does not create CoinPay checkout without server credentials", async () => { - const response = await fetch(`${baseUrl}/api/hire-us/coinpay-checkout`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}" +describe("www canonical redirect (proxy.ts)", () => { + it("301s www to the apex host over https, preserving path + query", () => { + const request = new NextRequest("https://www.logicsrc.com/openspec?ref=email", { + headers: { host: "www.logicsrc.com" } }); + const response = proxy(request); + + expect(response.status).toBe(301); + expect(response.headers.get("location")).toBe("https://logicsrc.com/openspec?ref=email"); + }); + + it("passes through apex requests untouched", () => { + const request = new NextRequest("https://logicsrc.com/hire-us", { + headers: { host: "logicsrc.com" } + }); + const response = proxy(request); + + // NextResponse.next() yields a non-redirect response. + expect(response.status).toBe(200); + expect(response.headers.get("location")).toBeNull(); + }); +}); + +describe("server-rendered page markup", () => { + it("includes canonical section content and the CoinPay connect action", () => { + const markup = renderPageMarkup(); + expect(markup).toContain("LogicSRC vs OpenSpec.dev"); + expect(markup).toContain("Open replacement architecture for secrets"); + expect(markup).toContain("/api/oauth/coinpay/start"); + expect(markup).toContain('id="project-request-form"'); + }); +}); + +describe("payment rail selection", () => { + it("prefers both when card and configured crypto are available", () => { + expect( + choosePaymentRail({ accepts_card: true, accepts_crypto: true, chains: ["USDC_POL"] }, "USDC_POL") + ).toEqual({ method: "both", currency: "usdc_pol", blockchain: "USDC_POL" }); + }); + + it("falls back to card when configured crypto is unavailable", () => { + expect( + choosePaymentRail({ accepts_card: true, accepts_crypto: false, chains: [] }, "USDC_POL") + ).toEqual({ method: "card", currency: "card", blockchain: null }); + }); + + it("uses crypto only when card is not enabled", () => { + expect( + choosePaymentRail({ accepts_card: false, accepts_crypto: true, chains: ["USDC_POL"] }, "USDC_POL") + ).toEqual({ method: "crypto", currency: "usdc_pol", blockchain: "USDC_POL" }); + }); + + it("returns null when no rail is available", () => { + expect(choosePaymentRail({ accepts_card: false, accepts_crypto: false, chains: [] }, "USDC_POL")).toBeNull(); + }); +}); + +describe("POST /api/hire-us/coinpay-checkout", () => { + it("does not create checkout without server credentials", async () => { + const response = await coinpayCheckout( + new NextRequest("http://localhost/api/hire-us/coinpay-checkout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}" + }) + ); const body = await response.json(); expect(response.status).toBe(503); @@ -80,18 +132,46 @@ describe("LogicSRC web contracts", () => { expect(body).toEqual({ success: false, error: "CoinPay checkout is not configured" }); }); - it("creates CoinPay checkout with card and crypto when both rails are available", async () => { - const { response, body, upstreamRequests } = await createCheckout({ - eligibility: { accepts_card: true, accepts_crypto: true, chains: ["USDC_POL"] }, - payment: { stripe_checkout_url: "https://checkout.stripe.test/session" } + it("creates a checkout with card and crypto when both rails are available", async () => { + process.env.COINPAY_API_KEY = "cp_test_key"; + process.env.COINPAY_API_URL = "https://coinpayportal.example"; + process.env.COINPAY_BUSINESS_ID = "business-123"; + process.env.COINPAY_ELIGIBILITY_MERCHANT_ID = "merchant-123"; + process.env.COINPAY_HIRE_US_BLOCKCHAIN = "USDC_POL"; + process.env.PUBLIC_URL = "https://logicsrc.test"; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/payments/merchant-eligibility")) { + return jsonResponse({ success: true, accepts_card: true, accepts_crypto: true, chains: ["USDC_POL"] }); + } + if (url.includes("/api/payments/create")) { + return jsonResponse( + { success: true, payment: { id: "pay_123", stripe_checkout_url: "https://checkout.stripe.test/session" } }, + 201 + ); + } + throw new Error(`unexpected fetch ${url}`); }); - const paymentRequest = upstreamRequests.find((request) => request.url === "/api/payments/create"); + + const response = await coinpayCheckout( + new NextRequest("http://localhost/api/hire-us/coinpay-checkout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "buyer@example.com" }) + }) + ); + const body = await response.json(); + + const createCall = fetchMock.mock.calls.find(([input]) => + (typeof input === "string" ? input : input.toString()).includes("/api/payments/create") + ); + const createBody = JSON.parse((createCall?.[1]?.body as string) ?? "{}"); expect(response.status).toBe(201); expect(body.payment.checkout_url).toBe("https://checkout.stripe.test/session"); - expect(paymentRequest?.method).toBe("POST"); - expect(paymentRequest?.authorization).toBe("Bearer cp_test_key"); - expect(paymentRequest?.body).toMatchObject({ + expect(createCall?.[1]?.headers).toMatchObject({ authorization: "Bearer cp_test_key" }); + expect(createBody).toMatchObject({ business_id: "business-123", amount_usd: 250, payment_method: "both", @@ -111,406 +191,234 @@ describe("LogicSRC web contracts", () => { }); }); - it("uses card-only checkout when Stripe is available and configured crypto is not", async () => { - const { response, body, upstreamRequests } = await createCheckout({ - eligibility: { accepts_card: true, accepts_crypto: false, chains: [] }, - payment: { stripe_checkout_url: "https://checkout.stripe.test/card-only" } + it("uses card-only checkout when configured crypto is not available", async () => { + process.env.COINPAY_API_KEY = "cp_test_key"; + process.env.COINPAY_API_URL = "https://coinpayportal.example"; + process.env.COINPAY_BUSINESS_ID = "business-123"; + process.env.COINPAY_ELIGIBILITY_MERCHANT_ID = "merchant-123"; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/payments/merchant-eligibility")) { + return jsonResponse({ success: true, accepts_card: true, accepts_crypto: false, chains: [] }); + } + return jsonResponse( + { success: true, payment: { id: "pay_123", stripe_checkout_url: "https://checkout.stripe.test/card-only" } }, + 201 + ); }); - const paymentRequest = upstreamRequests.find((request) => request.url === "/api/payments/create"); + + const response = await coinpayCheckout( + new NextRequest("http://localhost/api/hire-us/coinpay-checkout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "buyer@example.com" }) + }) + ); + const body = await response.json(); expect(response.status).toBe(201); expect(body.payment.checkout_url).toBe("https://checkout.stripe.test/card-only"); - expect(paymentRequest?.body).toMatchObject({ - payment_method: "card", - currency: "card" - }); - expect(paymentRequest?.body).not.toHaveProperty("blockchain"); }); - it("uses crypto-only checkout when Stripe is not enabled", async () => { - const { response, body, upstreamRequests } = await createCheckout({ - eligibility: { accepts_card: false, accepts_crypto: true, chains: ["USDC_POL"] }, - payment: {} - }); - const paymentRequest = upstreamRequests.find((request) => request.url === "/api/payments/create"); + it("does not create checkout when no payment rail is available", async () => { + process.env.COINPAY_API_KEY = "cp_test_key"; + process.env.COINPAY_API_URL = "https://coinpayportal.example"; + process.env.COINPAY_BUSINESS_ID = "business-123"; + process.env.COINPAY_ELIGIBILITY_MERCHANT_ID = "merchant-123"; - expect(response.status).toBe(201); - expect(body.payment.checkout_url).toBeNull(); - expect(body.payment.address).toBe("0xabc"); - expect(paymentRequest?.body).toMatchObject({ - payment_method: "crypto", - currency: "usdc_pol", - blockchain: "USDC_POL" - }); - }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => + jsonResponse({ success: true, accepts_card: false, accepts_crypto: false, chains: [] }) + ); - it("does not create CoinPay checkout when no payment rail is available", async () => { - const { response, body, upstreamRequests } = await createCheckout({ - eligibility: { accepts_card: false, accepts_crypto: false, chains: [] }, - payment: {} - }); + const response = await coinpayCheckout( + new NextRequest("http://localhost/api/hire-us/coinpay-checkout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "buyer@example.com" }) + }) + ); + const body = await response.json(); expect(response.status).toBe(503); - expect(body).toEqual({ - success: false, - error: "CoinPay checkout is not available for this merchant" - }); - expect(upstreamRequests.some((request) => request.url === "/api/payments/create")).toBe(false); + expect(body).toEqual({ success: false, error: "CoinPay checkout is not available for this merchant" }); + expect( + fetchMock.mock.calls.some(([input]) => + (typeof input === "string" ? input : input.toString()).includes("/api/payments/create") + ) + ).toBe(false); }); +}); - it("accepts Hire Us project requests before creating a recurring invoice", async () => { - const { appServer, appBaseUrl } = await startApp({}); - - try { - const response = await fetch(`${appBaseUrl}/api/hire-us/project-request`, { +describe("POST /api/hire-us/project-request", () => { + it("accepts a valid project request before invoicing", async () => { + const response = await projectRequest( + new NextRequest("http://localhost/api/hire-us/project-request", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ contact: "buyer@example.com", project: "Build a LogicSRC plugin and API contract for a recurring agent workflow." }) - }); - const body = await response.json(); + }) + ); + const body = await response.json(); - expect(response.status).toBe(202); - expect(body).toMatchObject({ - success: true, - request: { - status: "pending_acceptance", - amount_usd: 250, - interval: "week", - invoice: "created_after_acceptance" - } - }); - expect(body.request.id).toMatch(/^hire_/); - } finally { - appServer.kill(); - } + expect(response.status).toBe(202); + expect(body).toMatchObject({ + success: true, + request: { status: "pending_acceptance", amount_usd: 250, interval: "week", invoice: "created_after_acceptance" } + }); + expect(body.request.id).toMatch(/^hire_/); }); - it("accepts signed CoinPay payment webhooks", async () => { - const secret = "whsec_test"; - const { appServer, appBaseUrl } = await startApp({ - COINPAY_API_KEY: "cp_test_key", - COINPAY_MERCHANT_ID: "business-123", - COINPAY_WEBHOOK_SECRET: secret - }); - const payload = JSON.stringify({ - id: "evt_1", - type: "payment.forwarded", - data: { payment_id: "pay_123", status: "forwarded" } - }); - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signature = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex"); - - try { - const response = await fetch(`${appBaseUrl}/api/webhooks/coinpay`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-coinpay-signature": `t=${timestamp},v1=${signature}` - }, - body: payload - }); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body).toEqual({ received: true, complete: true, payment_id: "pay_123" }); - } finally { - appServer.kill(); - } - }); - - it("rejects unsigned CoinPay payment webhooks", async () => { - const { appServer, appBaseUrl } = await startApp({ - COINPAY_API_KEY: "cp_test_key", - COINPAY_MERCHANT_ID: "business-123", - COINPAY_WEBHOOK_SECRET: "whsec_test" - }); - - try { - const response = await fetch(`${appBaseUrl}/api/webhooks/coinpay`, { + it("rejects an incomplete project request", async () => { + const response = await projectRequest( + new NextRequest("http://localhost/api/hire-us/project-request", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "payment.confirmed", data: { payment_id: "pay_123" } }) - }); - const body = await response.json(); + body: JSON.stringify({ contact: "", project: "too short" }) + }) + ); - expect(response.status).toBe(401); - expect(body).toEqual({ success: false, error: "Invalid signature" }); - } finally { - appServer.kill(); - } - }); - - it("starts CoinPay OAuth with a registered callback and state cookie", async () => { - const { appServer, appBaseUrl } = await startApp({ - COINPAY_OAUTH_ISSUER: "https://coinpayportal.example", - COINPAY_OAUTH_CLIENT_ID: "cp_test_client", - COINPAY_OAUTH_CLIENT_SECRET: "cps_test_secret", - COINPAY_OAUTH_REDIRECT_URI: "https://logicsrc.com/api/oauth/coinpay/callback" - }); - - try { - const response = await fetch(`${appBaseUrl}/api/oauth/coinpay/start`, { redirect: "manual" }); - const location = new URL(response.headers.get("location") ?? ""); - - expect(response.status).toBe(302); - expect(location.origin).toBe("https://coinpayportal.example"); - expect(location.pathname).toBe("/api/oauth/authorize"); - expect(location.searchParams.get("response_type")).toBe("code"); - expect(location.searchParams.get("client_id")).toBe("cp_test_client"); - expect(location.searchParams.get("redirect_uri")).toBe("https://logicsrc.com/api/oauth/coinpay/callback"); - expect(location.searchParams.get("scope")).toBe("openid profile email"); - expect(location.searchParams.get("state")).toMatch(/^[a-f0-9]{32}$/); - expect(response.headers.get("set-cookie")).toContain("logicsrc_coinpay_oauth_state="); - } finally { - appServer.kill(); - } - }); - - it("exchanges CoinPay OAuth callback codes and stores a signed session", async () => { - const { fakeCoinPay, fakeCoinPayBaseUrl, tokenRequests } = await startFakeCoinPayOAuth(); - const { appServer, appBaseUrl } = await startApp({ - COINPAY_OAUTH_ISSUER: fakeCoinPayBaseUrl, - COINPAY_OAUTH_CLIENT_ID: "cp_test_client", - COINPAY_OAUTH_CLIENT_SECRET: "cps_test_secret", - COINPAY_OAUTH_REDIRECT_URI: "https://logicsrc.com/api/oauth/coinpay/callback", - LOGICSRC_SESSION_SECRET: "session_secret_for_tests" - }); - - try { - const response = await fetch(`${appBaseUrl}/api/oauth/coinpay/callback?code=auth_code_123&state=state_123`, { - redirect: "manual", - headers: { cookie: "logicsrc_coinpay_oauth_state=state_123" } - }); - const cookieHeader = response.headers.get("set-cookie") ?? ""; - const sessionCookie = cookieHeader.match(/logicsrc_coinpay_session=([^;]+)/)?.[0]; - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBe("/?coinpay_oauth=connected"); - expect(sessionCookie).toBeTruthy(); - expect(tokenRequests[0]).toMatchObject({ - grant_type: "authorization_code", - code: "auth_code_123", - redirect_uri: "https://logicsrc.com/api/oauth/coinpay/callback", - client_id: "cp_test_client", - client_secret: "cps_test_secret" - }); - - const sessionResponse = await fetch(`${appBaseUrl}/api/oauth/coinpay/session`, { - headers: { cookie: sessionCookie ?? "" } - }); - const sessionBody = await sessionResponse.json(); - - expect(sessionBody).toMatchObject({ - authenticated: true, - user: { - provider: "coinpay", - sub: "merchant-123", - email: "merchant@example.com", - name: "Merchant User", - scope: "openid profile email" - } - }); - } finally { - appServer.kill(); - await close(fakeCoinPay); - } + expect(response.status).toBe(422); }); }); -async function createCheckout({ - eligibility, - payment -}: { - eligibility: { accepts_card: boolean; accepts_crypto: boolean; chains: string[] }; - payment: Record; -}) { - const upstreamRequests: Array<{ - method?: string; - url?: string; - authorization?: string; - body?: Record; - }> = []; - - const fakeCoinPay = createServer((request, response) => { - let rawBody = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { - rawBody += chunk; - }); - request.on("end", () => { - upstreamRequests.push({ - method: request.method, - url: request.url, - authorization: request.headers.authorization, - body: rawBody ? JSON.parse(rawBody) : undefined - }); - - if (request.method === "GET" && request.url?.startsWith("/api/payments/merchant-eligibility")) { - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - success: true, - merchant_id: "merchant-123", - ...eligibility - })); - return; - } - - if (request.method === "POST" && request.url === "/api/payments/create") { - response.writeHead(201, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - success: true, - payment: { - id: "pay_123", - amount_usd: 250, - blockchain: "USDC_POL", - amount_crypto: "499.5", - payment_address: "0xabc", - expires_at: "2030-01-01T00:00:00.000Z", - status: "pending", - ...payment - } - })); - return; - } - - response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ success: false, error: "not found" })); - }); +describe("CoinPay OAuth", () => { + it("returns 503 from start when OAuth is not configured", async () => { + const response = await oauthStart(); + expect(response.status).toBe(503); }); - await listen(fakeCoinPay); - const fakeCoinPayPort = (fakeCoinPay.address() as AddressInfo).port; - const appPort = nextCheckoutPort; - nextCheckoutPort += 1; - const { appServer, appBaseUrl } = await startApp({ - PORT: String(appPort), - COINPAY_API_KEY: "cp_test_key", - COINPAY_API_URL: `http://127.0.0.1:${fakeCoinPayPort}`, - COINPAY_BUSINESS_ID: "business-123", - COINPAY_ELIGIBILITY_MERCHANT_ID: "merchant-123", - COINPAY_HIRE_US_BLOCKCHAIN: "USDC_POL", - PUBLIC_URL: "https://logicsrc.test" + it("starts OAuth with a registered callback and state cookie", async () => { + process.env.COINPAY_OAUTH_ISSUER = "https://coinpayportal.example"; + process.env.COINPAY_OAUTH_CLIENT_ID = "cp_test_client"; + process.env.COINPAY_OAUTH_CLIENT_SECRET = "cps_test_secret"; + process.env.COINPAY_OAUTH_REDIRECT_URI = "https://logicsrc.com/api/oauth/coinpay/callback"; + + const response = await oauthStart(); + const location = new URL(response.headers.get("location") ?? ""); + + expect(response.status).toBe(302); + expect(location.origin).toBe("https://coinpayportal.example"); + expect(location.pathname).toBe("/api/oauth/authorize"); + expect(location.searchParams.get("client_id")).toBe("cp_test_client"); + expect(location.searchParams.get("redirect_uri")).toBe("https://logicsrc.com/api/oauth/coinpay/callback"); + expect(location.searchParams.get("state")).toMatch(/^[a-f0-9]{32}$/); + expect(response.headers.get("set-cookie")).toContain("logicsrc_coinpay_oauth_state="); }); - try { - await waitForServer(appBaseUrl); + it("rejects a callback with mismatched state", async () => { + process.env.COINPAY_OAUTH_ISSUER = "https://coinpayportal.example"; + process.env.COINPAY_OAUTH_CLIENT_ID = "cp_test_client"; + process.env.COINPAY_OAUTH_CLIENT_SECRET = "cps_test_secret"; + process.env.COINPAY_OAUTH_REDIRECT_URI = "https://logicsrc.com/api/oauth/coinpay/callback"; - const response = await fetch(`${appBaseUrl}/api/hire-us/coinpay-checkout`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ email: "buyer@example.com" }) + const response = await oauthCallback( + new NextRequest("http://localhost/api/oauth/coinpay/callback?code=abc&state=wrong", { + headers: { cookie: "logicsrc_coinpay_oauth_state=expected" } + }) + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/?coinpay_oauth=error&error=invalid_state"); + }); + + it("exchanges a callback code and stores a verifiable session", async () => { + process.env.COINPAY_OAUTH_ISSUER = "https://coinpayportal.example"; + process.env.COINPAY_OAUTH_CLIENT_ID = "cp_test_client"; + process.env.COINPAY_OAUTH_CLIENT_SECRET = "cps_test_secret"; + process.env.COINPAY_OAUTH_REDIRECT_URI = "https://logicsrc.com/api/oauth/coinpay/callback"; + process.env.LOGICSRC_SESSION_SECRET = "session_secret_for_tests"; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/oauth/token")) { + return jsonResponse({ access_token: "access_token_123", token_type: "Bearer", scope: "openid profile email" }); + } + if (url.includes("/api/oauth/userinfo")) { + return jsonResponse({ sub: "merchant-123", email: "merchant@example.com", name: "Merchant User" }); + } + throw new Error(`unexpected fetch ${url}`); }); + + const callback = await oauthCallback( + new NextRequest("http://localhost/api/oauth/coinpay/callback?code=auth_code_123&state=state_123", { + headers: { cookie: "logicsrc_coinpay_oauth_state=state_123" } + }) + ); + + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).toBe("/?coinpay_oauth=connected"); + + const setCookies = callback.headers.getSetCookie(); + const sessionCookie = setCookies.find((cookie) => cookie.startsWith("logicsrc_coinpay_session=")); + expect(sessionCookie).toBeTruthy(); + const sessionValue = decodeURIComponent(sessionCookie!.split(";")[0].split("=")[1]); + + const session = await oauthSession( + new NextRequest("http://localhost/api/oauth/coinpay/session", { + headers: { cookie: `logicsrc_coinpay_session=${encodeURIComponent(sessionValue)}` } + }) + ); + const sessionBody = await session.json(); + + expect(sessionBody).toMatchObject({ + authenticated: true, + user: { provider: "coinpay", sub: "merchant-123", email: "merchant@example.com", name: "Merchant User" } + }); + }); +}); + +describe("session signing", () => { + it("verifies a session it signed and rejects tampering", () => { + process.env.LOGICSRC_SESSION_SECRET = "session_secret_for_tests"; + const token = signSession({ provider: "coinpay", sub: "merchant-123" }); + expect(verifySession(token)).toMatchObject({ provider: "coinpay", sub: "merchant-123" }); + expect(verifySession(`${token}tampered`)).toBeNull(); + }); +}); + +describe("POST /api/webhooks/coinpay", () => { + it("returns 503 when no webhook secret is configured", async () => { + const response = await coinpayWebhook( + new NextRequest("http://localhost/api/webhooks/coinpay", { method: "POST", body: "{}" }) + ); + expect(response.status).toBe(503); + }); + + it("rejects unsigned webhooks", async () => { + process.env.COINPAY_WEBHOOK_SECRET = "whsec_test"; + const response = await coinpayWebhook( + new NextRequest("http://localhost/api/webhooks/coinpay", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "payment.confirmed", data: { payment_id: "pay_123" } }) + }) + ); + expect(response.status).toBe(401); + }); + + it("accepts signed webhooks", async () => { + const secret = "whsec_test"; + process.env.COINPAY_WEBHOOK_SECRET = secret; + const payload = JSON.stringify({ id: "evt_1", type: "payment.forwarded", data: { payment_id: "pay_123" } }); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex"); + + expect(verifyCoinPayWebhook(payload, `t=${timestamp},v1=${signature}`, secret)).toBe(true); + + const response = await coinpayWebhook( + new NextRequest("http://localhost/api/webhooks/coinpay", { + method: "POST", + headers: { "content-type": "application/json", "x-coinpay-signature": `t=${timestamp},v1=${signature}` }, + body: payload + }) + ); const body = await response.json(); - return { response, body, upstreamRequests }; - } finally { - appServer.kill(); - await close(fakeCoinPay); - } -} - -async function startApp(env: Record) { - const appPort = env.PORT ? Number(env.PORT) : nextCheckoutPort; - if (!env.PORT) { - nextCheckoutPort += 1; - } - const appBaseUrl = `http://127.0.0.1:${appPort}`; - const appServer = spawn(process.execPath, ["server.js"], { - cwd: new URL("..", import.meta.url), - env: { - ...process.env, - PORT: String(appPort), - COINPAY_API_URL: "https://coinpayportal.example", - PUBLIC_URL: "https://logicsrc.test", - ...env - } + expect(response.status).toBe(200); + expect(body).toEqual({ received: true, complete: true, payment_id: "pay_123" }); }); - - await waitForServer(appBaseUrl); - return { appServer, appBaseUrl }; -} - -async function startFakeCoinPayOAuth() { - const tokenRequests: Array> = []; - const fakeCoinPay = createServer((request, response) => { - let rawBody = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { - rawBody += chunk; - }); - request.on("end", () => { - if (request.method === "POST" && request.url === "/api/oauth/token") { - tokenRequests.push(Object.fromEntries(new URLSearchParams(rawBody))); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - access_token: "access_token_123", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "refresh_token_123", - scope: "openid profile email" - })); - return; - } - - if (request.method === "GET" && request.url === "/api/oauth/userinfo") { - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - sub: "merchant-123", - email: "merchant@example.com", - name: "Merchant User" - })); - return; - } - - response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ error: "not found" })); - }); - }); - - await listen(fakeCoinPay); - const fakeCoinPayPort = (fakeCoinPay.address() as AddressInfo).port; - return { - fakeCoinPay, - fakeCoinPayBaseUrl: `http://127.0.0.1:${fakeCoinPayPort}`, - tokenRequests - }; -} - -function listen(server: HttpServer) { - return new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); - }); -} - -function close(server: HttpServer) { - return new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -async function waitForServer(serverBaseUrl: string) { - let lastError: unknown; - for (let attempt = 0; attempt < 40; attempt += 1) { - try { - const response = await fetch(`${serverBaseUrl}/health`); - if (response.ok) { - return; - } - } catch (error) { - lastError = error; - } - - await delay(250); - } - - throw new Error(`LogicSRC web server did not start: ${String(lastError)}`); -} +}); diff --git a/apps/logicsrc-web/index.html b/apps/logicsrc-web/index.html deleted file mode 100644 index 3a11ecb..0000000 --- a/apps/logicsrc-web/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - LogicSRC - - -
- - - diff --git a/apps/logicsrc-web/next.config.ts b/apps/logicsrc-web/next.config.ts new file mode 100644 index 0000000..f307edf --- /dev/null +++ b/apps/logicsrc-web/next.config.ts @@ -0,0 +1,22 @@ +import type { NextConfig } from "next"; + +// The CommandBoard API (boards, tasks, plugins, /health) runs as its own +// service. In the old custom server.js it was mounted in-process; here we proxy +// those paths to it via rewrites. Our own /api routes (hire-us, oauth/coinpay, +// webhooks) are filesystem routes and match before these afterFiles rewrites. +const commandboardApiUrl = process.env.COMMANDBOARD_API_URL; + +const nextConfig: NextConfig = { + async rewrites() { + if (!commandboardApiUrl) return []; + const base = commandboardApiUrl.replace(/\/$/, ""); + return { + afterFiles: [ + { source: "/health", destination: `${base}/health` }, + { source: "/api/:path*", destination: `${base}/api/:path*` } + ] + }; + } +}; + +export default nextConfig; diff --git a/apps/logicsrc-web/package.json b/apps/logicsrc-web/package.json index c56e0d6..686091e 100644 --- a/apps/logicsrc-web/package.json +++ b/apps/logicsrc-web/package.json @@ -1,23 +1,27 @@ { "name": "@logicsrc/web", "version": "0.1.0", - "description": "LogicSRC standards and open specification PWA.", - "type": "module", + "private": true, + "description": "LogicSRC standards and open specification PWA (Next.js).", "scripts": { - "build": "node scripts/check-assets.js && vite build", - "dev": "vite --host 0.0.0.0", - "start": "node server.js", + "dev": "next dev -p 5174", + "build": "next build", + "start": "next start", "test": "vitest run contract", "test:contract": "vitest run contract", "test:e2e": "playwright test" }, "dependencies": { - "@logicsrc/commandboard-api": "file:../commandboard-api", - "typescript": "^5.9.3", - "vite": "^7.2.6" + "next": "16.2.6", + "react": "19.2.0", + "react-dom": "19.2.0" }, "devDependencies": { "@playwright/test": "^1.57.0", + "@types/node": "^24.10.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.9.3", "vitest": "^4.0.8" } } diff --git a/apps/logicsrc-web/playwright.config.ts b/apps/logicsrc-web/playwright.config.ts index e85e8b4..ecb23f3 100644 --- a/apps/logicsrc-web/playwright.config.ts +++ b/apps/logicsrc-web/playwright.config.ts @@ -23,9 +23,9 @@ export default defineConfig({ } ], webServer: { - command: "npm run dev -- --port 5174", + command: "npm run dev", url: "http://127.0.0.1:5174", reuseExistingServer: !process.env.CI, - timeout: 30_000 + timeout: 120_000 } }); diff --git a/apps/logicsrc-web/scripts/check-assets.js b/apps/logicsrc-web/scripts/check-assets.js deleted file mode 100644 index ac70984..0000000 --- a/apps/logicsrc-web/scripts/check-assets.js +++ /dev/null @@ -1,7 +0,0 @@ -import { accessSync } from "node:fs"; - -for (const file of ["index.html", "public/manifest.webmanifest", "public/icon.svg", "public/service-worker.js", "public/sitemap.xml", "public/blog/rss.xml", "src/main.ts", "src/styles.css"]) { - accessSync(new URL(`../${file}`, import.meta.url)); -} - -console.log("logicsrc-web assets verified"); diff --git a/apps/logicsrc-web/server.js b/apps/logicsrc-web/server.js deleted file mode 100644 index 9250baa..0000000 --- a/apps/logicsrc-web/server.js +++ /dev/null @@ -1,696 +0,0 @@ -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; -import { createServer } from "node:http"; -import { extname, join, normalize, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createCommandBoardServer } from "../commandboard-api/dist/index.js"; - -const appDirectory = fileURLToPath(new URL(".", import.meta.url)); -const distDirectory = resolve(appDirectory, "dist"); -const indexFile = join(distDirectory, "index.html"); -loadLocalEnv(resolve(appDirectory, "../..", ".env")); -const apiServer = createCommandBoardServer(); -const port = Number(process.env.PORT ?? 4174); -const coinPayOAuthStateCookie = "logicsrc_coinpay_oauth_state"; -const coinPaySessionCookie = "logicsrc_coinpay_session"; - -const mimeTypes = { - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".ico": "image/x-icon", - ".js": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".svg": "image/svg+xml", - ".webmanifest": "application/manifest+json; charset=utf-8", - ".xml": "application/xml; charset=utf-8" -}; - -createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); - - if (url.pathname === "/api/hire-us/coinpay-checkout") { - handleHireUsCoinPayCheckout(request, response); - return; - } - - if (url.pathname === "/api/oauth/coinpay/start") { - handleCoinPayOAuthStart(request, response); - return; - } - - if (url.pathname === "/api/oauth/coinpay/callback") { - handleCoinPayOAuthCallback(request, response, url); - return; - } - - if (url.pathname === "/api/oauth/coinpay/session") { - handleCoinPayOAuthSession(request, response); - return; - } - - if (url.pathname === "/api/hire-us/project-request") { - handleHireUsProjectRequest(request, response); - return; - } - - if (url.pathname === "/api/webhooks/coinpay") { - handleCoinPayWebhook(request, response); - return; - } - - if (url.pathname === "/health" || url.pathname.startsWith("/api/")) { - apiServer.emit("request", request, response); - return; - } - - if (request.method !== "GET" && request.method !== "HEAD") { - response.writeHead(405, { allow: "GET, HEAD" }); - response.end("Method not allowed"); - return; - } - - const file = resolveStaticPath(url.pathname); - if (!file) { - response.writeHead(403); - response.end("Forbidden"); - return; - } - - sendFile(file, request.method === "HEAD", response); -}).listen(port, () => { - console.log(`LogicSRC standards PWA listening on http://localhost:${port}`); -}); - -function resolveStaticPath(pathname) { - const decodedPath = decodeURIComponent(pathname); - const normalizedPath = normalize(decodedPath).replace(/^(\.\.[/\\])+/, ""); - let candidate = join(distDirectory, normalizedPath); - - if (!candidate.startsWith(distDirectory)) { - return null; - } - - if (existsSync(candidate) && statSync(candidate).isDirectory()) { - candidate = join(candidate, "index.html"); - } - - if (existsSync(candidate) && statSync(candidate).isFile()) { - return candidate; - } - - return indexFile; -} - -function sendFile(file, headOnly, response) { - if (!existsSync(file)) { - response.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); - response.end("Build output missing. Run `npm run build` before `npm start`."); - return; - } - - const extension = extname(file); - response.writeHead(200, { - "cache-control": extension === ".html" || extension === ".xml" ? "no-store" : "public, max-age=31536000, immutable", - "content-type": mimeTypes[extension] ?? "application/octet-stream" - }); - - if (headOnly) { - response.end(); - return; - } - - createReadStream(file).pipe(response); -} - -async function handleHireUsCoinPayCheckout(request, response) { - if (request.method !== "POST") { - sendJson(response, 405, { success: false, error: "Method not allowed" }, { allow: "POST" }); - return; - } - - const apiKey = process.env.COINPAY_API_KEY; - const eligibilityApiKey = process.env.COINPAY_ELIGIBILITY_API_KEY || process.env.COINPAY_AGENT_API_KEY || apiKey; - const businessId = process.env.COINPAY_BUSINESS_ID || process.env.COINPAY_MERCHANT_ID; - const eligibilityMerchantId = process.env.COINPAY_ELIGIBILITY_MERCHANT_ID || process.env.COINPAY_MERCHANT_ID; - const apiUrl = process.env.COINPAY_API_URL || "https://coinpayportal.com"; - const blockchain = process.env.COINPAY_HIRE_US_BLOCKCHAIN || "USDC_POL"; - const publicUrl = process.env.PUBLIC_URL || "https://logicsrc.com"; - - if (!apiKey || !businessId) { - sendJson(response, 503, { success: false, error: "CoinPay checkout is not configured" }); - return; - } - - try { - const body = await readJson(request); - const buyerEmail = typeof body.email === "string" ? body.email.trim().slice(0, 160) : ""; - const eligibility = await fetchMerchantEligibility(apiUrl, eligibilityApiKey, eligibilityMerchantId); - const paymentRail = choosePaymentRail(eligibility, blockchain); - - if (!paymentRail) { - sendJson(response, 503, { - success: false, - error: "CoinPay checkout is not available for this merchant" - }); - return; - } - - const checkoutResponse = await fetch(new URL("/api/payments/create", apiUrl), { - method: "POST", - headers: { - authorization: `Bearer ${apiKey}`, - "content-type": "application/json" - }, - body: JSON.stringify({ - business_id: businessId, - amount_usd: 250, - payment_method: paymentRail.method, - currency: paymentRail.currency, - ...(paymentRail.blockchain ? { blockchain: paymentRail.blockchain } : {}), - description: "LogicSRC Hire Us - $250/week", - success_url: `${publicUrl}/hire-us?payment=success`, - cancel_url: `${publicUrl}/hire-us?payment=cancelled`, - redirect_url: `${publicUrl}/hire-us?payment=coinpay`, - webhook_url: `${publicUrl}/api/webhooks/coinpay`, - metadata: { - product: "logicsrc-hire-us", - interval: "week", - source: "logicsrc.com/hire-us", - ...(buyerEmail ? { buyer_email: buyerEmail } : {}) - } - }) - }); - - const responseText = await checkoutResponse.text(); - const payload = parseJson(responseText); - - if (!checkoutResponse.ok || !payload.success) { - console.error("[coinpay] checkout create failed", { - status: checkoutResponse.status, - error: payload.error || responseText.slice(0, 300) - }); - sendJson(response, checkoutResponse.ok ? 502 : checkoutResponse.status, { - success: false, - error: payload.error || "CoinPay checkout failed" - }); - return; - } - - const payment = payload.payment || {}; - sendJson(response, 201, { - success: true, - payment: { - id: payment.id, - amount_usd: Number(payment.amount_usd ?? payment.amount ?? 250), - payment_method: payment.stripe_checkout_url ? "card" : paymentRail.method, - currency: payment.currency ?? payment.blockchain ?? paymentRail.blockchain ?? paymentRail.currency, - crypto_amount: payment.amount_crypto ?? payment.crypto_amount ?? null, - address: payment.payment_address ?? null, - qr_code: payment.qr_code ?? null, - expires_at: payment.expires_at ?? null, - status: payment.status ?? "pending", - checkout_url: payment.stripe_checkout_url ?? payload.checkout_url ?? payment.checkout_url ?? null - } - }); - } catch (error) { - console.error("[coinpay] checkout request failed", error); - sendJson(response, 500, { - success: false, - error: "Unable to reach CoinPay checkout" - }); - } -} - -function handleCoinPayOAuthStart(request, response) { - if (request.method !== "GET") { - sendJson(response, 405, { success: false, error: "Method not allowed" }, { allow: "GET" }); - return; - } - - const config = getCoinPayOAuthConfig(); - if (!config) { - sendJson(response, 503, { success: false, error: "CoinPay OAuth is not configured" }); - return; - } - - const state = randomBytes(16).toString("hex"); - const authorizeUrl = new URL("/api/oauth/authorize", config.issuer); - authorizeUrl.searchParams.set("response_type", "code"); - authorizeUrl.searchParams.set("client_id", config.clientId); - authorizeUrl.searchParams.set("redirect_uri", config.redirectUri); - authorizeUrl.searchParams.set("scope", config.scopes); - authorizeUrl.searchParams.set("state", state); - - response.writeHead(302, { - location: authorizeUrl.toString(), - "set-cookie": serializeCookie(coinPayOAuthStateCookie, state, { - maxAge: 600, - path: "/api/oauth/coinpay" - }) - }); - response.end(); -} - -async function handleCoinPayOAuthCallback(request, response, url) { - if (request.method !== "GET") { - sendJson(response, 405, { success: false, error: "Method not allowed" }, { allow: "GET" }); - return; - } - - const config = getCoinPayOAuthConfig(); - if (!config) { - sendJson(response, 503, { success: false, error: "CoinPay OAuth is not configured" }); - return; - } - - const callbackError = url.searchParams.get("error"); - if (callbackError) { - redirectWithOAuthStatus(response, "error", callbackError); - return; - } - - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const expectedState = parseCookies(request.headers.cookie)[coinPayOAuthStateCookie]; - - if (!code) { - redirectWithOAuthStatus(response, "error", "missing_code"); - return; - } - - if (!state || !expectedState || state !== expectedState) { - redirectWithOAuthStatus(response, "error", "invalid_state"); - return; - } - - try { - const tokenResponse = await fetch(new URL("/api/oauth/token", config.issuer), { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: config.redirectUri, - client_id: config.clientId, - client_secret: config.clientSecret - }) - }); - const tokenText = await tokenResponse.text(); - const tokenPayload = parseJson(tokenText); - - if (!tokenResponse.ok || typeof tokenPayload.access_token !== "string") { - console.error("[coinpay-oauth] token exchange failed", { - status: tokenResponse.status, - error: tokenPayload.error || tokenText.slice(0, 160) - }); - redirectWithOAuthStatus(response, "error", "token_exchange_failed"); - return; - } - - const userResponse = await fetch(new URL("/api/oauth/userinfo", config.issuer), { - headers: { authorization: `Bearer ${tokenPayload.access_token}` } - }); - const userText = await userResponse.text(); - const userInfo = userResponse.ok ? parseJson(userText) : {}; - - const session = signSession({ - provider: "coinpay", - sub: typeof userInfo.sub === "string" ? userInfo.sub : null, - email: typeof userInfo.email === "string" ? userInfo.email : null, - name: typeof userInfo.name === "string" ? userInfo.name : null, - scope: typeof tokenPayload.scope === "string" ? tokenPayload.scope : config.scopes, - connected_at: new Date().toISOString() - }); - - response.writeHead(302, { - location: "/?coinpay_oauth=connected", - "set-cookie": [ - serializeCookie(coinPaySessionCookie, session, { maxAge: 60 * 60 * 24 * 30, path: "/" }), - serializeCookie(coinPayOAuthStateCookie, "", { maxAge: 0, path: "/api/oauth/coinpay" }) - ] - }); - response.end(); - } catch (error) { - console.error("[coinpay-oauth] callback failed", error); - redirectWithOAuthStatus(response, "error", "callback_failed"); - } -} - -function handleCoinPayOAuthSession(request, response) { - if (request.method !== "GET") { - sendJson(response, 405, { success: false, error: "Method not allowed" }, { allow: "GET" }); - return; - } - - const sessionCookie = parseCookies(request.headers.cookie)[coinPaySessionCookie]; - const session = sessionCookie ? verifySession(sessionCookie) : null; - sendJson(response, 200, { - authenticated: !!session, - user: session - ? { - provider: session.provider, - sub: session.sub, - email: session.email, - name: session.name, - scope: session.scope, - connected_at: session.connected_at - } - : null - }); -} - -async function handleHireUsProjectRequest(request, response) { - if (request.method !== "POST") { - sendJson(response, 405, { success: false, error: "Method not allowed" }, { allow: "POST" }); - return; - } - - try { - const body = await readJson(request); - const contact = typeof body.contact === "string" ? body.contact.trim().slice(0, 160) : ""; - const project = typeof body.project === "string" ? body.project.trim().slice(0, 4000) : ""; - - if (!contact || project.length < 20) { - sendJson(response, 422, { - success: false, - error: "Contact and a project description are required" - }); - return; - } - - const requestId = `hire_${Date.now()}`; - console.log("[hire-us] project request received", { - id: requestId, - contact, - project_length: project.length, - plan: "250/week", - invoice: "pending_acceptance" - }); - - sendJson(response, 202, { - success: true, - request: { - id: requestId, - status: "pending_acceptance", - amount_usd: 250, - interval: "week", - invoice: "created_after_acceptance" - } - }); - } catch (error) { - console.error("[hire-us] project request failed", error); - sendJson(response, 500, { success: false, error: "Unable to submit project request" }); - } -} - -async function handleCoinPayWebhook(request, response) { - if (request.method !== "POST") { - sendJson(response, 405, { success: false, error: "Method not allowed" }, { allow: "POST" }); - return; - } - - const webhookSecret = process.env.COINPAY_WEBHOOK_SECRET; - if (!webhookSecret) { - sendJson(response, 503, { success: false, error: "CoinPay webhook is not configured" }); - return; - } - - const rawBody = await readText(request); - const signature = request.headers["x-coinpay-signature"]; - const signatureHeader = Array.isArray(signature) ? signature[0] : signature; - if (!verifyCoinPayWebhook(rawBody, signatureHeader, webhookSecret)) { - sendJson(response, 401, { success: false, error: "Invalid signature" }); - return; - } - - const payload = parseJson(rawBody); - const paymentId = payload?.data?.payment_id ?? payload?.payment_id ?? null; - const complete = payload?.type === "payment.confirmed" || payload?.type === "payment.forwarded"; - - console.log("[coinpay] webhook received", { - type: payload?.type ?? null, - payment_id: paymentId, - complete - }); - - sendJson(response, 200, { - received: true, - complete, - payment_id: paymentId - }); -} - -async function fetchMerchantEligibility(apiUrl, apiKey, merchantId) { - 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) { - console.warn("[coinpay] merchant eligibility unavailable", { - status: response.status, - error: payload.error || 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.filter((chain) => typeof chain === "string") : [] - }; - } catch (error) { - console.warn("[coinpay] merchant eligibility request failed", error); - return null; - } -} - -function choosePaymentRail(eligibility, configuredBlockchain) { - 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; -} - -function verifyCoinPayWebhook(rawBody, signatureHeader, secret) { - 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; - } -} - -function getCoinPayOAuthConfig() { - 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 redirectWithOAuthStatus(response, status, error) { - const location = new URL("/", process.env.PUBLIC_URL || "https://logicsrc.com"); - location.searchParams.set("coinpay_oauth", status); - if (error) { - location.searchParams.set("error", error); - } - - response.writeHead(302, { location: `${location.pathname}${location.search}` }); - response.end(); -} - -function serializeCookie(name, value, options = {}) { - 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("; "); -} - -function parseCookies(header) { - if (!header) return {}; - return Object.fromEntries( - header - .split(";") - .map((cookie) => cookie.trim()) - .filter(Boolean) - .map((cookie) => { - const separator = cookie.indexOf("="); - if (separator === -1) return [cookie, ""]; - return [ - cookie.slice(0, separator), - decodeURIComponent(cookie.slice(separator + 1)) - ]; - }) - ); -} - -function signSession(payload) { - const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const signature = createHmac("sha256", getSessionSecret()).update(encoded).digest("base64url"); - return `${encoded}.${signature}`; -} - -function verifySession(value) { - 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")); - } catch { - return null; - } -} - -function getSessionSecret() { - return process.env.LOGICSRC_SESSION_SECRET || process.env.COINPAY_OAUTH_CLIENT_SECRET || "logicsrc-dev-session-secret"; -} - -function loadLocalEnv(file) { - if (!existsSync(file)) return; - - for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - - const separator = trimmed.indexOf("="); - if (separator === -1) continue; - - const key = trimmed.slice(0, separator).trim(); - const rawValue = trimmed.slice(separator + 1).trim(); - if (!key || process.env[key] !== undefined) continue; - - process.env[key] = rawValue.replace(/^(['"])(.*)\1$/, "$2"); - } -} - -function parseJson(text) { - try { - return text ? JSON.parse(text) : {}; - } catch { - return {}; - } -} - -function readText(request) { - return new Promise((resolve, reject) => { - let body = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { - body += chunk; - if (body.length > 65536) { - reject(new Error("Request body too large")); - request.destroy(); - } - }); - request.on("end", () => resolve(body)); - request.on("error", reject); - }); -} - -function readJson(request) { - return new Promise((resolve, reject) => { - let body = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { - body += chunk; - if (body.length > 4096) { - reject(new Error("Request body too large")); - request.destroy(); - } - }); - request.on("end", () => { - if (!body.trim()) { - resolve({}); - return; - } - try { - resolve(JSON.parse(body)); - } catch { - reject(new Error("Invalid JSON")); - } - }); - request.on("error", reject); - }); -} - -function sendJson(response, status, body, headers = {}) { - response.writeHead(status, { - "cache-control": "no-store", - "content-type": "application/json; charset=utf-8", - ...headers - }); - response.end(JSON.stringify(body)); -} diff --git a/apps/logicsrc-web/src/app/[[...slug]]/page.tsx b/apps/logicsrc-web/src/app/[[...slug]]/page.tsx new file mode 100644 index 0000000..0647ce8 --- /dev/null +++ b/apps/logicsrc-web/src/app/[[...slug]]/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { renderPageMarkup } from "@/lib/page-markup"; +import { HomeInteractivity } from "@/components/home-interactivity"; + +// The legacy SPA served the same single page for every top-level path and just +// scrolled to the matching section. We preserve those URLs (they are canonical +// in sitemap.xml) by rendering the same page for each known route and 404ing +// anything else. +const KNOWN_ROUTES = new Set([ + "docs", + "blog", + "openspec", + "credential-sharing", + "hire-us", + "about", + "terms", + "privacy", + "agent-swarm", + "agentbyte" +]); + +export default async function Page({ + params +}: { + params: Promise<{ slug?: string[] }>; +}): Promise { + const { slug } = await params; + if (slug && slug.length > 0) { + if (slug.length > 1 || !KNOWN_ROUTES.has(slug[0])) { + notFound(); + } + } + + return ( + <> +
+ + + ); +} diff --git a/apps/logicsrc-web/src/app/api/hire-us/coinpay-checkout/route.ts b/apps/logicsrc-web/src/app/api/hire-us/coinpay-checkout/route.ts new file mode 100644 index 0000000..8175043 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/hire-us/coinpay-checkout/route.ts @@ -0,0 +1,95 @@ +import type { NextRequest } from "next/server"; +import { json } from "@/lib/http"; +import { choosePaymentRail, fetchMerchantEligibility, parseJson } from "@/lib/coinpay"; + +export const dynamic = "force-dynamic"; + +// POST /api/hire-us/coinpay-checkout — create a $250/week CoinPay checkout for +// the Hire Us plan, choosing card/crypto/both based on merchant eligibility. +export async function POST(request: NextRequest) { + const apiKey = process.env.COINPAY_API_KEY; + const eligibilityApiKey = process.env.COINPAY_ELIGIBILITY_API_KEY || process.env.COINPAY_AGENT_API_KEY || apiKey; + const businessId = process.env.COINPAY_BUSINESS_ID || process.env.COINPAY_MERCHANT_ID; + const eligibilityMerchantId = process.env.COINPAY_ELIGIBILITY_MERCHANT_ID || process.env.COINPAY_MERCHANT_ID; + const apiUrl = process.env.COINPAY_API_URL || "https://coinpayportal.com"; + const blockchain = process.env.COINPAY_HIRE_US_BLOCKCHAIN || "USDC_POL"; + const publicUrl = process.env.PUBLIC_URL || "https://logicsrc.com"; + + if (!apiKey || !businessId) { + return json({ success: false, error: "CoinPay checkout is not configured" }, 503); + } + + try { + const body = (await request.json().catch(() => ({}))) as Record; + const buyerEmail = typeof body.email === "string" ? body.email.trim().slice(0, 160) : ""; + const eligibility = await fetchMerchantEligibility(apiUrl, eligibilityApiKey, eligibilityMerchantId); + const paymentRail = choosePaymentRail(eligibility, blockchain); + + if (!paymentRail) { + return json({ success: false, error: "CoinPay checkout is not available for this merchant" }, 503); + } + + const checkoutResponse = await fetch(new URL("/api/payments/create", apiUrl), { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json" + }, + body: JSON.stringify({ + business_id: businessId, + amount_usd: 250, + payment_method: paymentRail.method, + currency: paymentRail.currency, + ...(paymentRail.blockchain ? { blockchain: paymentRail.blockchain } : {}), + description: "LogicSRC Hire Us - $250/week", + success_url: `${publicUrl}/hire-us?payment=success`, + cancel_url: `${publicUrl}/hire-us?payment=cancelled`, + redirect_url: `${publicUrl}/hire-us?payment=coinpay`, + webhook_url: `${publicUrl}/api/webhooks/coinpay`, + metadata: { + product: "logicsrc-hire-us", + interval: "week", + source: "logicsrc.com/hire-us", + ...(buyerEmail ? { buyer_email: buyerEmail } : {}) + } + }) + }); + + const responseText = await checkoutResponse.text(); + const payload = parseJson(responseText); + + if (!checkoutResponse.ok || payload.success !== true) { + console.error("[coinpay] checkout create failed", { + status: checkoutResponse.status, + error: (payload.error as string) || responseText.slice(0, 300) + }); + return json( + { success: false, error: (payload.error as string) || "CoinPay checkout failed" }, + checkoutResponse.ok ? 502 : checkoutResponse.status + ); + } + + const payment = (payload.payment as Record) || {}; + return json( + { + success: true, + payment: { + id: payment.id, + amount_usd: Number(payment.amount_usd ?? payment.amount ?? 250), + payment_method: payment.stripe_checkout_url ? "card" : paymentRail.method, + currency: payment.currency ?? payment.blockchain ?? paymentRail.blockchain ?? paymentRail.currency, + crypto_amount: payment.amount_crypto ?? payment.crypto_amount ?? null, + address: payment.payment_address ?? null, + qr_code: payment.qr_code ?? null, + expires_at: payment.expires_at ?? null, + status: payment.status ?? "pending", + checkout_url: payment.stripe_checkout_url ?? payload.checkout_url ?? payment.checkout_url ?? null + } + }, + 201 + ); + } catch (error) { + console.error("[coinpay] checkout request failed", error); + return json({ success: false, error: "Unable to reach CoinPay checkout" }, 500); + } +} diff --git a/apps/logicsrc-web/src/app/api/hire-us/project-request/route.ts b/apps/logicsrc-web/src/app/api/hire-us/project-request/route.ts new file mode 100644 index 0000000..9d5c409 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/hire-us/project-request/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from "next/server"; +import { json } from "@/lib/http"; + +export const dynamic = "force-dynamic"; + +// POST /api/hire-us/project-request — accept a Hire Us project request before a +// recurring CoinPay invoice is created (invoice is created after acceptance). +export async function POST(request: NextRequest) { + try { + const body = (await request.json().catch(() => ({}))) as Record; + const contact = typeof body.contact === "string" ? body.contact.trim().slice(0, 160) : ""; + const project = typeof body.project === "string" ? body.project.trim().slice(0, 4000) : ""; + + if (!contact || project.length < 20) { + return json({ success: false, error: "Contact and a project description are required" }, 422); + } + + const requestId = `hire_${Date.now()}`; + console.log("[hire-us] project request received", { + id: requestId, + contact, + project_length: project.length, + plan: "250/week", + invoice: "pending_acceptance" + }); + + return json( + { + success: true, + request: { + id: requestId, + status: "pending_acceptance", + amount_usd: 250, + interval: "week", + invoice: "created_after_acceptance" + } + }, + 202 + ); + } catch (error) { + console.error("[hire-us] project request failed", error); + return json({ success: false, error: "Unable to submit project request" }, 500); + } +} diff --git a/apps/logicsrc-web/src/app/api/oauth/coinpay/callback/route.ts b/apps/logicsrc-web/src/app/api/oauth/coinpay/callback/route.ts new file mode 100644 index 0000000..b206c41 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/oauth/coinpay/callback/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { json } from "@/lib/http"; +import { + COINPAY_OAUTH_STATE_COOKIE, + COINPAY_SESSION_COOKIE, + getCoinPayOAuthConfig, + parseJson, + serializeCookie, + signSession +} from "@/lib/coinpay"; + +export const dynamic = "force-dynamic"; + +function redirectWithOAuthStatus(status: string, error?: string): NextResponse { + const params = new URLSearchParams({ coinpay_oauth: status }); + if (error) { + params.set("error", error); + } + return new NextResponse(null, { status: 302, headers: { location: `/?${params.toString()}` } }); +} + +// GET /api/oauth/coinpay/callback — validate state, exchange the code for tokens, +// fetch userinfo, and store a signed session cookie. +export async function GET(request: NextRequest) { + const config = getCoinPayOAuthConfig(); + if (!config) { + return json({ success: false, error: "CoinPay OAuth is not configured" }, 503); + } + + const url = request.nextUrl; + const callbackError = url.searchParams.get("error"); + if (callbackError) { + return redirectWithOAuthStatus("error", callbackError); + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const expectedState = request.cookies.get(COINPAY_OAUTH_STATE_COOKIE)?.value; + + if (!code) { + return redirectWithOAuthStatus("error", "missing_code"); + } + + if (!state || !expectedState || state !== expectedState) { + return redirectWithOAuthStatus("error", "invalid_state"); + } + + try { + const tokenResponse = await fetch(new URL("/api/oauth/token", config.issuer), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret + }) + }); + const tokenText = await tokenResponse.text(); + const tokenPayload = parseJson(tokenText); + + if (!tokenResponse.ok || typeof tokenPayload.access_token !== "string") { + console.error("[coinpay-oauth] token exchange failed", { + status: tokenResponse.status, + error: (tokenPayload.error as string) || tokenText.slice(0, 160) + }); + return redirectWithOAuthStatus("error", "token_exchange_failed"); + } + + const userResponse = await fetch(new URL("/api/oauth/userinfo", config.issuer), { + headers: { authorization: `Bearer ${tokenPayload.access_token}` } + }); + const userText = await userResponse.text(); + const userInfo = userResponse.ok ? parseJson(userText) : {}; + + const session = signSession({ + provider: "coinpay", + sub: typeof userInfo.sub === "string" ? userInfo.sub : null, + email: typeof userInfo.email === "string" ? userInfo.email : null, + name: typeof userInfo.name === "string" ? userInfo.name : null, + scope: typeof tokenPayload.scope === "string" ? tokenPayload.scope : config.scopes, + connected_at: new Date().toISOString() + }); + + const response = new NextResponse(null, { + status: 302, + headers: { location: "/?coinpay_oauth=connected" } + }); + response.headers.append( + "set-cookie", + serializeCookie(COINPAY_SESSION_COOKIE, session, { maxAge: 60 * 60 * 24 * 30, path: "/" }) + ); + response.headers.append( + "set-cookie", + serializeCookie(COINPAY_OAUTH_STATE_COOKIE, "", { maxAge: 0, path: "/api/oauth/coinpay" }) + ); + return response; + } catch (error) { + console.error("[coinpay-oauth] callback failed", error); + return redirectWithOAuthStatus("error", "callback_failed"); + } +} diff --git a/apps/logicsrc-web/src/app/api/oauth/coinpay/session/route.ts b/apps/logicsrc-web/src/app/api/oauth/coinpay/session/route.ts new file mode 100644 index 0000000..81b9019 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/oauth/coinpay/session/route.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from "next/server"; +import { json } from "@/lib/http"; +import { COINPAY_SESSION_COOKIE, verifySession } from "@/lib/coinpay"; + +export const dynamic = "force-dynamic"; + +// GET /api/oauth/coinpay/session — report whether a valid CoinPay session cookie +// is present and, if so, the connected user. +export async function GET(request: NextRequest) { + const sessionCookie = request.cookies.get(COINPAY_SESSION_COOKIE)?.value; + const session = sessionCookie ? verifySession(sessionCookie) : null; + return json({ + authenticated: !!session, + user: session + ? { + provider: session.provider, + sub: session.sub, + email: session.email, + name: session.name, + scope: session.scope, + connected_at: session.connected_at + } + : null + }); +} diff --git a/apps/logicsrc-web/src/app/api/oauth/coinpay/start/route.ts b/apps/logicsrc-web/src/app/api/oauth/coinpay/start/route.ts new file mode 100644 index 0000000..229d7b6 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/oauth/coinpay/start/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { randomBytes } from "node:crypto"; +import { json } from "@/lib/http"; +import { COINPAY_OAUTH_STATE_COOKIE, getCoinPayOAuthConfig, serializeCookie } from "@/lib/coinpay"; + +export const dynamic = "force-dynamic"; + +// GET /api/oauth/coinpay/start — begin CoinPay OAuth: set a signed state cookie +// and redirect to the provider's authorize endpoint. +export async function GET() { + const config = getCoinPayOAuthConfig(); + if (!config) { + return json({ success: false, error: "CoinPay OAuth is not configured" }, 503); + } + + const state = randomBytes(16).toString("hex"); + const authorizeUrl = new URL("/api/oauth/authorize", config.issuer); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", config.clientId); + authorizeUrl.searchParams.set("redirect_uri", config.redirectUri); + authorizeUrl.searchParams.set("scope", config.scopes); + authorizeUrl.searchParams.set("state", state); + + return new NextResponse(null, { + status: 302, + headers: { + location: authorizeUrl.toString(), + "set-cookie": serializeCookie(COINPAY_OAUTH_STATE_COOKIE, state, { + maxAge: 600, + path: "/api/oauth/coinpay" + }) + } + }); +} diff --git a/apps/logicsrc-web/src/app/api/webhooks/coinpay/route.ts b/apps/logicsrc-web/src/app/api/webhooks/coinpay/route.ts new file mode 100644 index 0000000..2d06bf1 --- /dev/null +++ b/apps/logicsrc-web/src/app/api/webhooks/coinpay/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from "next/server"; +import { json } from "@/lib/http"; +import { parseJson, verifyCoinPayWebhook } from "@/lib/coinpay"; + +export const dynamic = "force-dynamic"; + +// POST /api/webhooks/coinpay — verify the signed CoinPay webhook and acknowledge. +export async function POST(request: NextRequest) { + const webhookSecret = process.env.COINPAY_WEBHOOK_SECRET; + if (!webhookSecret) { + return json({ success: false, error: "CoinPay webhook is not configured" }, 503); + } + + const rawBody = await request.text(); + const signatureHeader = request.headers.get("x-coinpay-signature"); + if (!verifyCoinPayWebhook(rawBody, signatureHeader, webhookSecret)) { + return json({ success: false, error: "Invalid signature" }, 401); + } + + const payload = parseJson(rawBody); + const data = payload.data as Record | undefined; + const paymentId = (data?.payment_id ?? payload.payment_id ?? null) as string | null; + const complete = payload.type === "payment.confirmed" || payload.type === "payment.forwarded"; + + console.log("[coinpay] webhook received", { + type: payload.type ?? null, + payment_id: paymentId, + complete + }); + + return json({ received: true, complete, payment_id: paymentId }); +} diff --git a/apps/logicsrc-web/src/app/layout.tsx b/apps/logicsrc-web/src/app/layout.tsx new file mode 100644 index 0000000..2fec15b --- /dev/null +++ b/apps/logicsrc-web/src/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import "../styles.css"; + +export const metadata: Metadata = { + title: "LogicSRC", + description: + "Open schemas, primitives, and conventions for coordination between humans, AI agents, plugins, payment systems, and hosted products.", + manifest: "/manifest.webmanifest" +}; + +export const viewport: Viewport = { + themeColor: "#101418", + width: "device-width", + initialScale: 1 +}; + +export default function RootLayout({ children }: { children: ReactNode }): ReactNode { + return ( + + {children} + + ); +} diff --git a/apps/logicsrc-web/src/components/home-interactivity.tsx b/apps/logicsrc-web/src/components/home-interactivity.tsx new file mode 100644 index 0000000..05487f4 --- /dev/null +++ b/apps/logicsrc-web/src/components/home-interactivity.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect } from "react"; + +// Client-side behavior for the LogicSRC page, ported from the legacy Vite +// main.ts: register the service worker, wire the hire-us project form, reflect +// CoinPay connection status on the Connect button, and scroll to the section +// that matches the current path. The markup these hooks target is rendered on +// the server by `renderPageMarkup`. +export function HomeInteractivity(): null { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/service-worker.js").catch(() => undefined); + } + + const buildParagraph = (text: string): HTMLParagraphElement => { + const paragraph = document.createElement("p"); + paragraph.textContent = text; + return paragraph; + }; + + const form = document.querySelector("#project-request-form"); + const onSubmit = async (event: Event): Promise => { + event.preventDefault(); + const button = document.querySelector("#project-request-button"); + const result = document.querySelector("#project-request-result"); + const contact = document.querySelector("#project-contact"); + const project = document.querySelector("#project-description"); + if (!button || !result || !contact || !project) return; + + button.disabled = true; + button.textContent = "Submitting..."; + result.replaceChildren(buildParagraph("Submitting project request.")); + + try { + const response = await fetch("/api/hire-us/project-request", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ contact: contact.value, project: project.value }) + }); + const payload = await response.json(); + + if (!response.ok || !payload.success) { + throw new Error(payload.error || "Project request could not be submitted."); + } + + result.replaceChildren( + buildParagraph("Request received. If it is a fit, we will send a $250/week recurring CoinPay invoice.") + ); + } catch (error) { + result.replaceChildren( + buildParagraph(error instanceof Error ? error.message : "Project request could not be submitted.") + ); + } finally { + button.disabled = false; + button.textContent = "Request review"; + } + }; + form?.addEventListener("submit", onSubmit); + + // Scroll to the section that matches the current path (mirrors the SPA). + const { pathname } = window.location; + if (pathname === "/agent-swarm") { + document.querySelector("#agent-swarm")?.scrollIntoView(); + } else if (pathname === "/agentbyte") { + document.querySelector("#agentbyte")?.scrollIntoView(); + } else { + const pageRoute = pathname.slice(1); + if ( + ["docs", "blog", "openspec", "credential-sharing", "hire-us", "about", "terms", "privacy"].includes(pageRoute) + ) { + document.querySelector(`#${pageRoute}`)?.scrollIntoView(); + } + } + + // CoinPay OAuth connection status: clean the query param and reflect state. + const coinpayParam = new URLSearchParams(window.location.search).get("coinpay_oauth"); + if (coinpayParam) { + const url = new URL(window.location.href); + url.searchParams.delete("coinpay_oauth"); + url.searchParams.delete("error"); + history.replaceState(null, "", url.pathname + (url.search || "")); + } + + const updateCoinPayButton = async (): Promise => { + const connectBtn = document.querySelector( + ".hero-actions a[href='/api/oauth/coinpay/start']" + ); + if (!connectBtn) return; + + try { + const res = await fetch("/api/oauth/coinpay/session"); + const data = await res.json(); + + if (data.authenticated && data.user) { + const label = data.user.email || data.user.name || data.user.sub || "CoinPay"; + connectBtn.textContent = `Connected: ${label}`; + connectBtn.style.background = "#3a9e7e"; + connectBtn.removeAttribute("href"); + connectBtn.style.cursor = "default"; + connectBtn.title = `Connected via CoinPay since ${new Date(data.user.connected_at).toLocaleDateString()}`; + } else if (coinpayParam === "connected") { + connectBtn.textContent = "CoinPay Connected"; + connectBtn.style.background = "#3a9e7e"; + } else if (coinpayParam === "error") { + connectBtn.textContent = "Connect CoinPay (retry)"; + } + } catch { + // session check failed — leave button as-is + } + }; + void updateCoinPayButton(); + + return () => { + form?.removeEventListener("submit", onSubmit); + }; + }, []); + + return null; +} diff --git a/apps/logicsrc-web/src/lib/coinpay.ts b/apps/logicsrc-web/src/lib/coinpay.ts new file mode 100644 index 0000000..aaa9fb9 --- /dev/null +++ b/apps/logicsrc-web/src/lib/coinpay.ts @@ -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 { + try { + return text ? (JSON.parse(text) as Record) : {}; + } catch { + return {}; + } +} + +export async function fetchMerchantEligibility( + apiUrl: string, + apiKey: string | undefined, + merchantId: string | undefined +): Promise { + 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 { + 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 | 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; + } 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("; "); +} diff --git a/apps/logicsrc-web/src/lib/http.ts b/apps/logicsrc-web/src/lib/http.ts new file mode 100644 index 0000000..cc11587 --- /dev/null +++ b/apps/logicsrc-web/src/lib/http.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +// JSON responses are never cached, matching the legacy server.js `sendJson`. +export function json(body: unknown, status = 200, headers: Record = {}): NextResponse { + return NextResponse.json(body, { + status, + headers: { "cache-control": "no-store", ...headers } + }); +} diff --git a/apps/logicsrc-web/src/main.ts b/apps/logicsrc-web/src/lib/page-markup.ts similarity index 78% rename from apps/logicsrc-web/src/main.ts rename to apps/logicsrc-web/src/lib/page-markup.ts index 26b9fb3..aa41a9b 100644 --- a/apps/logicsrc-web/src/main.ts +++ b/apps/logicsrc-web/src/lib/page-markup.ts @@ -1,4 +1,8 @@ -import "./styles.css"; +// Server-rendered markup for the LogicSRC single-page site. This is a faithful +// port of the legacy Vite `main.ts` innerHTML template: same data, same markup, +// same class hooks — now rendered on the server for SEO instead of in the +// browser. Interactivity (hire-us form, CoinPay button, section scroll) lives in +// the `home-interactivity` client component. const primitives = [ { name: "Identity", detail: "DIDs, OAuth accounts, profiles, and organization membership." }, @@ -103,7 +107,8 @@ const comparisonRows = [ } ]; -document.querySelector("#app")!.innerHTML = ` +export function renderPageMarkup(): string { + return `
`; - -if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - navigator.serviceWorker.register("/service-worker.js").catch(() => undefined); - }); } - -document.querySelector("#project-request-form")?.addEventListener("submit", async (event) => { - event.preventDefault(); - const button = document.querySelector("#project-request-button"); - const result = document.querySelector("#project-request-result"); - const contact = document.querySelector("#project-contact"); - const project = document.querySelector("#project-description"); - if (!button || !result || !contact || !project) return; - - button.disabled = true; - button.textContent = "Submitting..."; - result.replaceChildren(buildParagraph("Submitting project request.")); - - try { - const response = await fetch("/api/hire-us/project-request", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ contact: contact.value, project: project.value }) - }); - const payload = await response.json(); - - if (!response.ok || !payload.success) { - throw new Error(payload.error || "Project request could not be submitted."); - } - - result.replaceChildren(buildParagraph("Request received. If it is a fit, we will send a $250/week recurring CoinPay invoice.")); - } catch (error) { - result.replaceChildren( - buildParagraph(error instanceof Error ? error.message : "Project request could not be submitted.") - ); - } finally { - button.disabled = false; - button.textContent = "Request review"; - } -}); - -function buildCoinPayResult(payment: { - amount_usd?: number; - crypto_amount?: string | null; - currency?: string; - address?: string | null; - id?: string; - qr_code?: string | null; -}) { - const fragment = document.createDocumentFragment(); - const heading = document.createElement("strong"); - heading.textContent = "CoinPay payment ready"; - fragment.append(heading); - - const details = document.createElement("dl"); - details.append( - buildDetail("Amount", `$${payment.amount_usd ?? 250} / ${payment.crypto_amount ?? "quoted at checkout"} ${payment.currency ?? "USDC_POL"}`), - buildDetail("Address", payment.address ?? "Open CoinPay to complete payment", true), - buildDetail("Payment ID", payment.id ?? "pending", true) - ); - fragment.append(details); - - if (payment.qr_code) { - const image = document.createElement("img"); - image.src = payment.qr_code; - image.alt = "CoinPay payment QR code"; - fragment.append(image); - } - - return fragment; -} - -function buildDetail(label: string, value: string, code = false) { - const row = document.createElement("div"); - const term = document.createElement("dt"); - const definition = document.createElement("dd"); - term.textContent = label; - if (code) { - const codeElement = document.createElement("code"); - codeElement.textContent = value; - definition.append(codeElement); - } else { - definition.textContent = value; - } - row.append(term, definition); - return row; -} - -function buildParagraph(text: string) { - const paragraph = document.createElement("p"); - paragraph.textContent = text; - return paragraph; -} - -if (window.location.pathname === "/agent-swarm") { - document.querySelector("#agent-swarm")?.scrollIntoView(); -} - -if (window.location.pathname === "/agentbyte") { - document.querySelector("#agentbyte")?.scrollIntoView(); -} - -const pageRoute = window.location.pathname.slice(1); -if (["docs", "blog", "openspec", "credential-sharing", "hire-us", "about", "terms", "privacy"].includes(pageRoute)) { - document.querySelector(`#${pageRoute}`)?.scrollIntoView(); -} - -// CoinPay OAuth connection status -const coinpayParam = new URLSearchParams(window.location.search).get("coinpay_oauth"); -if (coinpayParam) { - const url = new URL(window.location.href); - url.searchParams.delete("coinpay_oauth"); - url.searchParams.delete("error"); - history.replaceState(null, "", url.pathname + (url.search || "")); -} - -async function updateCoinPayButton() { - const connectBtn = document.querySelector(".hero-actions a[href='/api/oauth/coinpay/start']"); - if (!connectBtn) return; - - try { - const res = await fetch("/api/oauth/coinpay/session"); - const data = await res.json(); - - if (data.authenticated && data.user) { - const label = data.user.email || data.user.name || data.user.sub || "CoinPay"; - connectBtn.textContent = `Connected: ${label}`; - connectBtn.style.background = "#3a9e7e"; - connectBtn.removeAttribute("href"); - connectBtn.style.cursor = "default"; - connectBtn.title = `Connected via CoinPay since ${new Date(data.user.connected_at).toLocaleDateString()}`; - } else if (coinpayParam === "connected") { - connectBtn.textContent = "CoinPay Connected"; - connectBtn.style.background = "#3a9e7e"; - } else if (coinpayParam === "error") { - connectBtn.textContent = "Connect CoinPay (retry)"; - } - } catch { - // session check failed — leave button as-is - } -} - -updateCoinPayButton(); diff --git a/apps/logicsrc-web/src/proxy.ts b/apps/logicsrc-web/src/proxy.ts new file mode 100644 index 0000000..4abbb77 --- /dev/null +++ b/apps/logicsrc-web/src/proxy.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +// Canonical host: 301 www.* to the bare apex domain over https, preserving +// path + query (e.g. https://www.logicsrc.com/foo -> https://logicsrc.com/foo). +// This is the Next 16 "proxy" (formerly middleware) entrypoint. +export function proxy(request: NextRequest): NextResponse { + const host = request.headers.get("host") ?? ""; + if (host.startsWith("www.")) { + const apexHost = host.slice("www.".length); + const { pathname, search } = request.nextUrl; + return NextResponse.redirect(`https://${apexHost}${pathname}${search}`, 301); + } + return NextResponse.next(); +} + +export const config = { + // Run on everything except Next's static assets and the favicon. + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] +}; diff --git a/apps/logicsrc-web/tsconfig.json b/apps/logicsrc-web/tsconfig.json new file mode 100644 index 0000000..dc1544c --- /dev/null +++ b/apps/logicsrc-web/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "e2e", + "contract", + "playwright.config.ts" + ] +} diff --git a/apps/logicsrc-web/vitest.config.ts b/apps/logicsrc-web/vitest.config.ts new file mode 100644 index 0000000..1e9471c --- /dev/null +++ b/apps/logicsrc-web/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)) + } + }, + test: { + environment: "node", + include: ["contract/**/*.test.ts"] + } +}); diff --git a/package-lock.json b/package-lock.json index 4656fe7..7ec763e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,89 +126,19 @@ "name": "@logicsrc/web", "version": "0.1.0", "dependencies": { - "@logicsrc/commandboard-api": "file:../commandboard-api", - "typescript": "^5.9.3", - "vite": "^7.2.6" + "next": "16.2.6", + "react": "19.2.0", + "react-dom": "19.2.0" }, "devDependencies": { "@playwright/test": "^1.57.0", + "@types/node": "^24.10.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.9.3", "vitest": "^4.0.8" } }, - "apps/logicsrc-web/node_modules/vite": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -931,6 +861,520 @@ "hono": "^4" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1086,6 +1530,152 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -1099,7 +1689,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.60.0" @@ -1752,6 +2342,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1837,6 +2436,26 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2163,6 +2782,12 @@ "node": ">=18" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -2249,6 +2874,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3206,6 +3838,87 @@ "node": ">= 0.6" } }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -3337,7 +4050,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.60.0" @@ -3356,7 +4069,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -3369,7 +4082,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3460,6 +4172,27 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3583,6 +4316,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3643,6 +4382,64 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3775,6 +4572,29 @@ "dev": true, "license": "MIT" }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3831,8 +4651,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.22.4",