From b1d8fe475a8458edf4aaf42fb07a37f6cb20e547 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 6 Jun 2026 19:42:00 +0000 Subject: [PATCH] feat: add c0mpute plugin and hire-us request flow --- .env.example | 9 + README.md | 2 + apps/commandboard-api/package.json | 1 + apps/commandboard-api/src/contract.test.ts | 51 +- apps/commandboard-api/src/index.ts | 58 +- .../commandboard-web/e2e/commandboard.spec.ts | 12 +- apps/commandboard-web/src/main.ts | 5 +- .../contract/logicsrc-web.contract.test.ts | 426 +++++++++++++- apps/logicsrc-web/e2e/logicsrc.spec.ts | 9 +- apps/logicsrc-web/server.js | 522 +++++++++++++++++- apps/logicsrc-web/src/main.ts | 73 +-- apps/logicsrc-web/src/styles.css | 46 ++ docs/plugins.md | 1 + package-lock.json | 15 + package.json | 2 +- plugins/c0mpute/README.md | 11 + plugins/c0mpute/package.json | 18 + plugins/c0mpute/src/index.ts | 40 ++ plugins/c0mpute/src/manifest.ts | 21 + plugins/c0mpute/tsconfig.json | 8 + 20 files changed, 1271 insertions(+), 59 deletions(-) create mode 100644 plugins/c0mpute/README.md create mode 100644 plugins/c0mpute/package.json create mode 100644 plugins/c0mpute/src/index.ts create mode 100644 plugins/c0mpute/src/manifest.ts create mode 100644 plugins/c0mpute/tsconfig.json diff --git a/.env.example b/.env.example index 4c67759..77b32ab 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,17 @@ LOGICSRC_SCHEMA_VERSION=0.1 COINPAY_API_URL= COINPAY_API_KEY= COINPAY_MERCHANT_ID= +COINPAY_BUSINESS_ID= +COINPAY_ELIGIBILITY_MERCHANT_ID= +COINPAY_ELIGIBILITY_API_KEY= COINPAY_HIRE_US_BLOCKCHAIN=USDC_POL COINPAY_WEBHOOK_SECRET= +COINPAY_OAUTH_ISSUER=https://coinpayportal.com +COINPAY_OAUTH_CLIENT_ID= +COINPAY_OAUTH_CLIENT_SECRET= +COINPAY_OAUTH_REDIRECT_URI=https://logicsrc.com/api/oauth/coinpay/callback +COINPAY_OAUTH_SCOPES=openid profile email +LOGICSRC_SESSION_SECRET= UGIG_API_URL= UGIG_API_KEY= diff --git a/README.md b/README.md index bcd02a7..7b7b468 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ packages/ plugins/ coinpay default DID, wallet, payment, and escrow plugin ugig default jobs and gigs marketplace plugin + c0mpute work-in-progress compute jobs and worker pools plugin docs/ specs, CLI conventions, permissions, and roadmap notes scripts/ @@ -59,4 +60,5 @@ It provides read-only resources for docs and schemas, validation/example tools, - Credential Sharing OpenSpec for .env, Doppler, Railway variables, and GitHub Secrets. - CoinPay as the default payment, DID, wallet, and escrow plugin. - uGig as the default jobs and gigs marketplace plugin. +- c0mpute as a work-in-progress compute jobs and worker pools plugin. - Installer, update/upgrade, remove/uninstall workflows. diff --git a/apps/commandboard-api/package.json b/apps/commandboard-api/package.json index 4439505..999cf6b 100644 --- a/apps/commandboard-api/package.json +++ b/apps/commandboard-api/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@logicsrc/plugin-core": "file:../../packages/plugin-core", + "@logicsrc/plugin-c0mpute": "file:../../plugins/c0mpute", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-sh1pt": "file:../../plugins/sh1pt", "@logicsrc/plugin-ugig": "file:../../plugins/ugig", diff --git a/apps/commandboard-api/src/contract.test.ts b/apps/commandboard-api/src/contract.test.ts index 82fd32e..2c20969 100644 --- a/apps/commandboard-api/src/contract.test.ts +++ b/apps/commandboard-api/src/contract.test.ts @@ -39,7 +39,7 @@ describe("CommandBoard API contracts", () => { expect(body).toEqual({ ok: true, service: "commandboard-api" }); }); - it("exposes default plugin contract including sh1pt", async () => { + it("exposes default plugin contract including product plugins", async () => { const response = await fetch(`${baseUrl}/api/plugins`); const body = await response.json() as { plugins: Array<{ id: string; enabled: boolean; capabilities: string[] }>; @@ -47,12 +47,17 @@ describe("CommandBoard API contracts", () => { }; expect(response.status).toBe(200); - expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt"]); + expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute"]); expect(body.plugins.find((plugin) => plugin.id === "sh1pt")).toMatchObject({ enabled: true, capabilities: expect.arrayContaining(["projects.sync", "actions.publish", "deployments.status"]) }); + expect(body.plugins.find((plugin) => plugin.id === "c0mpute")).toMatchObject({ + enabled: true, + capabilities: expect.arrayContaining(["compute.jobs.sync", "compute.jobs.dispatch", "compute.workers.sync"]) + }); expect(body.capabilities["actions.publish"]).toEqual(["sh1pt"]); + expect(body.capabilities["compute.jobs.dispatch"]).toEqual(["c0mpute"]); }); it("exposes sh1pt project and action contracts", async () => { @@ -83,6 +88,48 @@ describe("CommandBoard API contracts", () => { }); }); + it("exposes work-in-progress c0mpute jobs and worker contracts", async () => { + const jobsResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/jobs`); + const jobsBody = await jobsResponse.json() as { jobs: Array<{ id: string; board: string; status: string; provider: string }> }; + const workersResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/workers`); + const workersBody = await workersResponse.json() as { workers: Array<{ id: string; status: string; capacity: string }> }; + + expect(jobsResponse.status).toBe(200); + expect(jobsBody.jobs[0]).toMatchObject({ id: "compute_job_1", board: "/projects/c0mpute", status: "draft", provider: "c0mpute.com" }); + expect(workersResponse.status).toBe(200); + expect(workersBody.workers[0]).toMatchObject({ id: "worker_pool_1", status: "preview", capacity: "wip" }); + }); + + it("accepts work-in-progress c0mpute dispatch and quote requests", async () => { + const dispatchResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/jobs/dispatch`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ job_id: "compute_job_1" }) + }); + const dispatchBody = await dispatchResponse.json() as { accepted: boolean; job_id: string; status: string; board: string }; + const quoteResponse = await fetch(`${baseUrl}/api/plugins/c0mpute/quotes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workload: "agent-run-smoke-test" }) + }); + const quoteBody = await quoteResponse.json() as { accepted: boolean; workload: string; provider: string; status: string }; + + expect(dispatchResponse.status).toBe(202); + expect(dispatchBody).toEqual({ + accepted: true, + job_id: "compute_job_1", + status: "queued", + board: "/projects/c0mpute" + }); + expect(quoteResponse.status).toBe(202); + expect(quoteBody).toMatchObject({ + accepted: true, + workload: "agent-run-smoke-test", + provider: "c0mpute.com", + status: "draft" + }); + }); + it("rejects invalid LogicSRC task payloads", async () => { const response = await fetch(`${baseUrl}/api/tasks`, { method: "POST", diff --git a/apps/commandboard-api/src/index.ts b/apps/commandboard-api/src/index.ts index d49f175..1e23416 100644 --- a/apps/commandboard-api/src/index.ts +++ b/apps/commandboard-api/src/index.ts @@ -1,18 +1,20 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { pathToFileURL } from "node:url"; import { createPluginRegistry } from "@logicsrc/plugin-core"; +import { c0mputePlugin } from "@logicsrc/plugin-c0mpute"; import { coinPayPlugin } from "@logicsrc/plugin-coinpay"; import { sh1ptPlugin } from "@logicsrc/plugin-sh1pt"; import { uGigPlugin } from "@logicsrc/plugin-ugig"; import { schemas, validate } from "@logicsrc/validators"; -const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin]); +const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin]); const boards = [ { path: "/general", title: "General", description: "CommandBoard.run general discussion." }, { path: "/gigs", title: "Gigs", description: "Paid work, uGig imports, and LogicSRC tasks." }, { path: "/agents", title: "Agents", description: "Agent registration, runs, and capabilities." }, - { path: "/projects/sh1pt", title: "sh1pt", description: "Project actions, releases, artifacts, and delivery status." } + { path: "/projects/sh1pt", title: "sh1pt", description: "Project actions, releases, artifacts, and delivery status." }, + { path: "/projects/c0mpute", title: "c0mpute", description: "Compute jobs, worker pools, usage, and settlement status." } ]; const tasks = [ @@ -41,6 +43,15 @@ const sh1ptActions = [ { id: "action_deploy_preview", title: "Deploy preview", publishable: true } ]; +const c0mputeJobs = [ + { id: "compute_job_1", board: "/projects/c0mpute", status: "draft", workload: "agent-run-smoke-test", provider: "c0mpute.com" }, + { id: "compute_job_2", board: "/projects/c0mpute", status: "queued", workload: "openspec-index-build", provider: "c0mpute.com" } +]; + +const c0mputeWorkers = [ + { id: "worker_pool_1", region: "us-west", status: "preview", capacity: "wip" } +]; + export function createCommandBoardServer() { return createServer(async (request, response) => { try { @@ -127,6 +138,49 @@ async function route(request: IncomingMessage, response: ServerResponse) { return; } + if (request.method === "GET" && url.pathname === "/api/plugins/c0mpute/jobs") { + json(response, 200, { jobs: c0mputeJobs }); + return; + } + + if (request.method === "GET" && url.pathname === "/api/plugins/c0mpute/workers") { + json(response, 200, { workers: c0mputeWorkers }); + return; + } + + if (request.method === "POST" && url.pathname === "/api/plugins/c0mpute/jobs/dispatch") { + const body = await readJson(request); + if (!isRecord(body) || typeof body.job_id !== "string") { + json(response, 422, { error: "Expected job_id" }); + return; + } + + json(response, 202, { + accepted: true, + job_id: body.job_id, + status: "queued", + board: typeof body.board === "string" ? body.board : "/projects/c0mpute" + }); + return; + } + + if (request.method === "POST" && url.pathname === "/api/plugins/c0mpute/quotes") { + const body = await readJson(request); + if (!isRecord(body) || typeof body.workload !== "string") { + json(response, 422, { error: "Expected workload" }); + return; + } + + json(response, 202, { + accepted: true, + quote_id: `quote_${Date.now()}`, + workload: body.workload, + provider: "c0mpute.com", + status: "draft" + }); + return; + } + if (request.method === "GET" && url.pathname === "/api/schemas") { json(response, 200, { schemas: Object.keys(schemas) }); return; diff --git a/apps/commandboard-web/e2e/commandboard.spec.ts b/apps/commandboard-web/e2e/commandboard.spec.ts index c8a1bfe..08594b4 100644 --- a/apps/commandboard-web/e2e/commandboard.spec.ts +++ b/apps/commandboard-web/e2e/commandboard.spec.ts @@ -10,13 +10,15 @@ test.describe("CommandBoard.run PWA", () => { await expect(page.getByText("QA checkout flow")).toBeVisible(); }); - test("shows default plugin status including sh1pt", async ({ page }) => { + test("shows default plugin status including product plugins", async ({ page }) => { await page.goto("/"); await expect(page.getByText("CoinPay").last()).toBeVisible(); await expect(page.getByText("uGig").last()).toBeVisible(); await expect(page.getByText("sh1pt").last()).toBeVisible(); + await expect(page.getByText("c0mpute").last()).toBeVisible(); await expect(page.getByText("projects, actions, and releases")).toBeVisible(); + await expect(page.getByText("c0mpute enabled · compute jobs and worker pools")).toBeVisible(); }); test("surfaces sh1pt project activity", async ({ page }) => { @@ -26,4 +28,12 @@ test.describe("CommandBoard.run PWA", () => { await expect(page.getByText("Release action published")).toBeVisible(); await expect(page.getByText("/projects/sh1pt · deployment ready")).toBeVisible(); }); + + test("surfaces c0mpute preview activity", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("/projects/c0mpute", { exact: true })).toBeVisible(); + await expect(page.getByText("Compute job queued")).toBeVisible(); + await expect(page.getByText("/projects/c0mpute · worker pool preview")).toBeVisible(); + }); }); diff --git a/apps/commandboard-web/src/main.ts b/apps/commandboard-web/src/main.ts index e2502c6..18edad5 100644 --- a/apps/commandboard-web/src/main.ts +++ b/apps/commandboard-web/src/main.ts @@ -4,13 +4,15 @@ const boards = [ { name: "/gigs", count: 12, label: "Paid tasks and uGig jobs" }, { name: "/agents", count: 4, label: "Agent registrations and runs" }, { name: "/qa", count: 7, label: "Testing, reports, acceptance" }, - { name: "/projects/sh1pt", count: 5, label: "Actions, releases, delivery" } + { name: "/projects/sh1pt", count: 5, label: "Actions, releases, delivery" }, + { name: "/projects/c0mpute", count: 2, label: "Compute jobs and worker pools" } ]; const tasks = [ { tag: "TASK", title: "QA checkout flow", meta: "25 USDC · submitted · qa-agent-01.coinpay" }, { tag: "uGig", title: "Senior AI Engineer remote", meta: "/gigs · synced from uGig" }, { tag: "sh1pt", title: "Release action published", meta: "/projects/sh1pt · deployment ready" }, + { tag: "c0mpute", title: "Compute job queued", meta: "/projects/c0mpute · worker pool preview" }, { tag: "RUN", title: "crawlproof-bot completed task_123", meta: "logs available" } ]; @@ -96,6 +98,7 @@ document.querySelector("#app")!.innerHTML = `

CoinPay enabled · default payment and DID

uGig enabled · default jobs marketplace

sh1pt enabled · projects, actions, and releases

+

c0mpute enabled · compute jobs and worker pools

diff --git a/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts index ef7fc7e..001a1d7 100644 --- a/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts +++ b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts @@ -1,11 +1,15 @@ 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"; let server: ChildProcessWithoutNullStreams; const port = 4291; const baseUrl = `http://127.0.0.1:${port}`; +let nextCheckoutPort = 4292; beforeAll(async () => { accessSync(new URL("../dist/index.html", import.meta.url)); @@ -19,7 +23,7 @@ beforeAll(async () => { } }); - await waitForServer(); + await waitForServer(baseUrl); }); afterAll(() => { @@ -75,13 +79,429 @@ describe("LogicSRC web contracts", () => { expect(response.headers.get("cache-control")).toBe("no-store"); 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" } + }); + const paymentRequest = upstreamRequests.find((request) => request.url === "/api/payments/create"); + + 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({ + business_id: "business-123", + amount_usd: 250, + payment_method: "both", + currency: "usdc_pol", + blockchain: "USDC_POL", + description: "LogicSRC Hire Us - $250/week", + success_url: "https://logicsrc.test/hire-us?payment=success", + cancel_url: "https://logicsrc.test/hire-us?payment=cancelled", + redirect_url: "https://logicsrc.test/hire-us?payment=coinpay", + webhook_url: "https://logicsrc.test/api/webhooks/coinpay", + metadata: { + product: "logicsrc-hire-us", + interval: "week", + source: "logicsrc.com/hire-us", + buyer_email: "buyer@example.com" + } + }); + }); + + 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" } + }); + const paymentRequest = upstreamRequests.find((request) => request.url === "/api/payments/create"); + + 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"); + + 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" + }); + }); + + 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: {} + }); + + 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); + }); + + 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`, { + 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(); + + 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(); + } + }); + + 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`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "payment.confirmed", data: { payment_id: "pay_123" } }) + }); + const body = await response.json(); + + 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); + } + }); }); -async function waitForServer() { +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" })); + }); + }); + + 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" + }); + + try { + await waitForServer(appBaseUrl); + + 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 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 + } + }); + + 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(`${baseUrl}/health`); + const response = await fetch(`${serverBaseUrl}/health`); if (response.ok) { return; } diff --git a/apps/logicsrc-web/e2e/logicsrc.spec.ts b/apps/logicsrc-web/e2e/logicsrc.spec.ts index 03a4f94..a52fc40 100644 --- a/apps/logicsrc-web/e2e/logicsrc.spec.ts +++ b/apps/logicsrc-web/e2e/logicsrc.spec.ts @@ -35,16 +35,19 @@ test.describe("LogicSRC PWA", () => { await expect(page.getByText("/privacy · Privacy")).toBeVisible(); }); - test("renders Hire Us offer and CoinPay payment CTA", async ({ page }) => { + test("renders Hire Us project request flow", async ({ page }) => { await page.goto("/hire-us"); await expect(page.getByRole("heading", { name: "Hire Us", exact: true })).toBeVisible(); - await expect(page.locator(".price-row strong", { hasText: "$500" })).toBeVisible(); + await expect(page.locator(".price-row strong", { hasText: "$250" })).toBeVisible(); await expect(page.getByText("per week")).toBeVisible(); await expect(page.getByText("open infrastructure and open specs for AI agent systems")).toBeVisible(); - await expect(page.getByRole("button", { name: "Pay with CoinPay" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Request review" })).toBeVisible(); + await expect(page.getByPlaceholder("you@example.com")).toBeVisible(); + await expect(page.getByPlaceholder("Describe the agent workflow")).toBeVisible(); await expect(page.getByText("without exposing merchant credentials to the browser")).toBeVisible(); await expect(page.getByText("COINPAY_PRODUCT=logicsrc-hire-us")).toBeVisible(); + await expect(page.getByText("COINPAY_STATUS=pending_acceptance")).toBeVisible(); }); test("serves sitemap and RSS XML endpoints", async ({ request }) => { diff --git a/apps/logicsrc-web/server.js b/apps/logicsrc-web/server.js index e208df7..9250baa 100644 --- a/apps/logicsrc-web/server.js +++ b/apps/logicsrc-web/server.js @@ -1,4 +1,5 @@ -import { createReadStream, existsSync, statSync } from "node:fs"; +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"; @@ -7,8 +8,11 @@ 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", @@ -30,6 +34,31 @@ createServer((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; @@ -101,11 +130,14 @@ async function handleHireUsCoinPayCheckout(request, response) { } const apiKey = process.env.COINPAY_API_KEY; - const merchantId = process.env.COINPAY_MERCHANT_ID; + 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) { + if (!apiKey || !businessId) { sendJson(response, 503, { success: false, error: "CoinPay checkout is not configured" }); return; } @@ -113,6 +145,17 @@ async function handleHireUsCoinPayCheckout(request, response) { 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: { @@ -120,24 +163,33 @@ async function handleHireUsCoinPayCheckout(request, response) { "content-type": "application/json" }, body: JSON.stringify({ - amount: 500, - currency: "USD", - blockchain, - description: "LogicSRC Hire Us - $500/week", + 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", - ...(merchantId ? { merchant_id: merchantId } : {}), ...(buyerEmail ? { buyer_email: buyerEmail } : {}) - }, - redirect_url: `${process.env.PUBLIC_URL || "https://logicsrc.com"}/hire-us?payment=coinpay` + } }) }); - const payload = await checkoutResponse.json().catch(() => ({})); + 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" @@ -150,24 +202,464 @@ async function handleHireUsCoinPayCheckout(request, response) { success: true, payment: { id: payment.id, - amount_usd: Number(payment.amount_usd ?? payment.amount ?? 500), - currency: payment.currency ?? payment.blockchain ?? blockchain, + 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 ?? null + 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: error instanceof Error ? error.message : "Unable to create CoinPay checkout" + 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 = ""; diff --git a/apps/logicsrc-web/src/main.ts b/apps/logicsrc-web/src/main.ts index c258d9e..3ec6b61 100644 --- a/apps/logicsrc-web/src/main.ts +++ b/apps/logicsrc-web/src/main.ts @@ -64,7 +64,7 @@ const pages = [ { id: "blog", title: "Blog", detail: "Project notes for LogicSRC, AgentSwarm, AgentByte, OpenSpec workflows, and reference implementations." }, { id: "openspec", title: "OpenSpec", detail: "Comparison and compatibility notes for OpenSpec.dev-style repo-local specs, proposals, tasks, and deltas." }, { id: "credential-sharing", title: "Credential Sharing", detail: "Open replacement architecture for portable secret sync across .env, Doppler, Railway variables, GitHub Secrets, and future providers." }, - { id: "hire-us", title: "Hire Us", detail: "$500/week LogicSRC work on open infrastructure, specs, AI agent workflows, and reference implementations paid through CoinPay." }, + { id: "hire-us", title: "Hire Us", detail: "$250/week LogicSRC work on open infrastructure, specs, AI agent workflows, and reference implementations paid through CoinPay after project acceptance." }, { id: "about", title: "About", detail: "LogicSRC is the Profullstack open specification project for human and AI agent coordination." }, { id: "terms", title: "Terms", detail: "Draft terms will cover acceptable use, reference implementation boundaries, and hosted-product responsibilities." }, { id: "privacy", title: "Privacy", detail: "Draft privacy notes will cover telemetry, audit events, identity data, and hosted-product data boundaries." } @@ -136,6 +136,9 @@ document.querySelector("#app")!.innerHTML = `

Profullstack open spec project

LogicSRC

Open schemas, primitives, and conventions for coordination between humans, AI agents, plugins, payment systems, and hosted products.

+
0.1draft spec @@ -319,7 +322,7 @@ logicsrc credentials plan --from doppler --to github-secrets

Hire Us

-

$500/week for LogicSRC work using open infrastructure and open specs for AI agent systems.

+

$250/week for accepted LogicSRC work using open infrastructure and open specs for AI agent systems.

@@ -327,14 +330,24 @@ logicsrc credentials plan --from doppler --to github-secrets

Open-spec AI agent implementation help

Hire us to turn agent ideas into portable LogicSRC specs, CLIs, SDKs, MCP resources, PWAs, APIs, and provider-neutral plugin workflows. We prioritize auditable contracts, repo-local artifacts, and integrations that can move between model providers and infrastructure.

- $500 + $250 per week
-
- - Read specs -
-
+
+ + +
+ + Read specs +
+
+
@@ -346,12 +359,13 @@ logicsrc credentials plan --from doppler --to github-secrets `).join("")}
-

CoinPay checkout hook

-

The primary CTA creates a CoinPay payment request for the weekly plan without exposing merchant credentials to the browser.

+

CoinPay recurring invoice

+

After we accept the project, we create a recurring CoinPay invoice for the weekly plan without exposing merchant credentials to the browser.

COINPAY_ORG=profullstack
 COINPAY_PRODUCT=logicsrc-hire-us
-COINPAY_AMOUNT_USD=500
-COINPAY_INTERVAL=week
+COINPAY_AMOUNT_USD=250 +COINPAY_INTERVAL=week +COINPAY_STATUS=pending_acceptance
@@ -384,41 +398,38 @@ if ("serviceWorker" in navigator) { }); } -document.querySelector("#coinpay-checkout-button")?.addEventListener("click", async () => { - const button = document.querySelector("#coinpay-checkout-button"); - const result = document.querySelector("#coinpay-result"); - if (!button || !result) return; +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 = "Creating payment..."; - result.replaceChildren(buildParagraph("Creating CoinPay payment request.")); + button.textContent = "Submitting..."; + result.replaceChildren(buildParagraph("Submitting project request.")); try { - const response = await fetch("/api/hire-us/coinpay-checkout", { + const response = await fetch("/api/hire-us/project-request", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({}) + body: JSON.stringify({ contact: contact.value, project: project.value }) }); const payload = await response.json(); if (!response.ok || !payload.success) { - throw new Error(payload.error || "CoinPay payment could not be created."); + throw new Error(payload.error || "Project request could not be submitted."); } - const payment = payload.payment; - if (payment.checkout_url) { - window.location.href = payment.checkout_url; - return; - } - - result.replaceChildren(buildCoinPayResult(payment)); + 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 : "CoinPay payment could not be created.") + buildParagraph(error instanceof Error ? error.message : "Project request could not be submitted.") ); } finally { button.disabled = false; - button.textContent = "Pay with CoinPay"; + button.textContent = "Request review"; } }); @@ -437,7 +448,7 @@ function buildCoinPayResult(payment: { const details = document.createElement("dl"); details.append( - buildDetail("Amount", `$${payment.amount_usd ?? 500} / ${payment.crypto_amount ?? "quoted at checkout"} ${payment.currency ?? "USDC_POL"}`), + 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) ); diff --git a/apps/logicsrc-web/src/styles.css b/apps/logicsrc-web/src/styles.css index 6b4eb25..1610c83 100644 --- a/apps/logicsrc-web/src/styles.css +++ b/apps/logicsrc-web/src/styles.css @@ -139,6 +139,13 @@ h1 { line-height: 1.55; } +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin-top: 1rem; +} + .status-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -477,6 +484,45 @@ pre { gap: 0.65rem; } +.project-request-form { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.project-request-form label { + display: grid; + gap: 0.35rem; +} + +.project-request-form label span { + color: #8ee4c9; + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.project-request-form input, +.project-request-form textarea { + width: 100%; + border: 1px solid #3f5049; + border-radius: 6px; + background: #171d1a; + color: #f6f7f4; + font: inherit; + line-height: 1.45; + padding: 0.7rem 0.75rem; +} + +.project-request-form textarea { + resize: vertical; +} + +.project-request-form input::placeholder, +.project-request-form textarea::placeholder { + color: #7f8b84; +} + .button-primary, .button-secondary { display: inline-flex; diff --git a/docs/plugins.md b/docs/plugins.md index 610569b..8686e8c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -6,6 +6,7 @@ Default plugins: - CoinPay: DID auth, wallet, payment, escrow, refunds, tips, payment webhooks, and payment reputation. - uGig: job import, gig publishing, candidate/agent linking, bid sync, marketplace publishing, and reputation sync. +- c0mpute: compute job dispatch, worker pool sync, usage reporting, quote creation, settlement status, and compute reputation events. - Credential Sharing: provider-neutral secret sync plans, approvals, rollbacks, and audit events. Coming soon plugin specs: diff --git a/package-lock.json b/package-lock.json index a95b2eb..4656fe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "name": "@logicsrc/commandboard-api", "version": "0.1.0", "dependencies": { + "@logicsrc/plugin-c0mpute": "file:../../plugins/c0mpute", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-core": "file:../../packages/plugin-core", "@logicsrc/plugin-sh1pt": "file:../../plugins/sh1pt", @@ -987,6 +988,10 @@ "resolved": "apps/commandboard-web", "link": true }, + "node_modules/@logicsrc/plugin-c0mpute": { + "resolved": "plugins/c0mpute", + "link": true + }, "node_modules/@logicsrc/plugin-coinpay": { "resolved": "plugins/coinpay", "link": true @@ -4728,6 +4733,16 @@ "vitest": "^4.0.8" } }, + "plugins/c0mpute": { + "name": "@logicsrc/plugin-c0mpute", + "version": "0.1.0", + "dependencies": { + "@logicsrc/plugin-core": "file:../../packages/plugin-core" + }, + "devDependencies": { + "vitest": "^4.0.8" + } + }, "plugins/coinpay": { "name": "@logicsrc/plugin-coinpay", "version": "0.1.0", diff --git a/package.json b/package.json index dd84515..17eda3c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", diff --git a/plugins/c0mpute/README.md b/plugins/c0mpute/README.md new file mode 100644 index 0000000..457041b --- /dev/null +++ b/plugins/c0mpute/README.md @@ -0,0 +1,11 @@ +# c0mpute Plugin + +c0mpute is a work-in-progress CommandBoard.run plugin for compute job dispatch, worker pool sync, usage reporting, quote creation, settlement status, and compute-backed reputation events. + +Environment variables: + +```txt +C0MPUTE_API_URL +C0MPUTE_API_KEY +C0MPUTE_WEBHOOK_SECRET +``` diff --git a/plugins/c0mpute/package.json b/plugins/c0mpute/package.json new file mode 100644 index 0000000..00bb373 --- /dev/null +++ b/plugins/c0mpute/package.json @@ -0,0 +1,18 @@ +{ + "name": "@logicsrc/plugin-c0mpute", + "version": "0.1.0", + "description": "CommandBoard.run compute jobs, worker pools, usage, and settlement plugin for c0mpute.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run src --passWithNoTests" + }, + "dependencies": { + "@logicsrc/plugin-core": "file:../../packages/plugin-core" + }, + "devDependencies": { + "vitest": "^4.0.8" + } +} diff --git a/plugins/c0mpute/src/index.ts b/plugins/c0mpute/src/index.ts new file mode 100644 index 0000000..d41aef1 --- /dev/null +++ b/plugins/c0mpute/src/index.ts @@ -0,0 +1,40 @@ +import type { PluginDefinition } from "@logicsrc/plugin-core"; +import { c0mputeManifest } from "./manifest.js"; + +export const c0mputePlugin: PluginDefinition = { + manifest: c0mputeManifest, + configDefaults: { + enabled: true, + work_in_progress: true, + default_compute_provider: true, + api_url: "${C0MPUTE_API_URL}", + api_key: "${C0MPUTE_API_KEY}", + webhook_secret: "${C0MPUTE_WEBHOOK_SECRET}", + default_board: "/projects/c0mpute" + }, + routes: [ + { method: "GET", path: "/api/plugins/c0mpute/jobs", capability: "compute.jobs.sync" }, + { method: "POST", path: "/api/plugins/c0mpute/jobs/dispatch", capability: "compute.jobs.dispatch" }, + { method: "GET", path: "/api/plugins/c0mpute/workers", capability: "compute.workers.sync" }, + { method: "POST", path: "/api/plugins/c0mpute/quotes", capability: "compute.quotes.create" }, + { method: "POST", path: "/api/plugins/c0mpute/webhooks/compute-status", capability: "webhook.compute_status" } + ], + events: [ + { event: "task.created", capability: "compute.quotes.create" }, + { event: "run.requested", capability: "compute.jobs.dispatch" }, + { event: "usage.reported", capability: "compute.usage.report" }, + { event: "settlement.completed", capability: "reputation.compute_event" } + ], + permissions: [ + "compute:jobs:read", + "compute:jobs:dispatch", + "compute:workers:read", + "compute:quotes:create", + "compute:usage:report", + "compute:settlements:read", + "reputation:sync" + ], + tuiPanels: [{ id: "c0mpute-status", title: "c0mpute Jobs" }] +}; + +export { c0mputeManifest }; diff --git a/plugins/c0mpute/src/manifest.ts b/plugins/c0mpute/src/manifest.ts new file mode 100644 index 0000000..713160b --- /dev/null +++ b/plugins/c0mpute/src/manifest.ts @@ -0,0 +1,21 @@ +import type { PluginManifest } from "@logicsrc/plugin-core"; + +export const c0mputeManifest: PluginManifest = { + id: "c0mpute", + name: "c0mpute", + version: "0.1.0", + type: ["compute", "jobs", "workers", "usage", "settlement"], + default: true, + capabilities: [ + "compute.jobs.sync", + "compute.jobs.dispatch", + "compute.workers.sync", + "compute.quotes.create", + "compute.usage.report", + "compute.settlements.status", + "webhook.compute_status", + "reputation.compute_event" + ], + commands: ["c0mpute", "compute", "workers", "quotes"], + env: ["C0MPUTE_API_URL", "C0MPUTE_API_KEY", "C0MPUTE_WEBHOOK_SECRET"] +}; diff --git a/plugins/c0mpute/tsconfig.json b/plugins/c0mpute/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/plugins/c0mpute/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}