diff --git a/.gitignore b/.gitignore index f9e7535..e270169 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,12 @@ dist-ssr # TypeScript incremental build *.tsbuildinfo - -# agent bridge flow storage -.justapi/ + +# agent bridge flow storage +.justapi/ + +# Cloudflare / OpenNext +.open-next/ +.wrangler/ +.dev.vars +cloudflare-env.d.ts diff --git a/README.md b/README.md index ab7cf14..ce1d6b0 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,25 @@ OpenAPI and fan endpoints out as nodes. ## Stack -- Next.js 15 (App Router) +- Next.js 15 (App Router) on **Cloudflare Workers** via `@opennextjs/cloudflare` - React 18 - @xyflow/react (React Flow) for the canvas -- Zustand for state +- Zustand for client state +- **better-auth** (email/password + API keys) on **Cloudflare D1** (Drizzle ORM) +- **R2** for share snapshots - Tailwind CSS ## Develop ```bash pnpm install -pnpm dev +pnpm db:migrate:local # apply auth schema to the local D1 +pnpm dev # next dev on :3100, Cloudflare bindings via miniflare ``` +Then open the app, create an account, and you're on the canvas. Auth secrets +live in `.dev.vars` (`BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`). + ## Build ```bash @@ -28,8 +34,11 @@ pnpm start ## Layout -- `app/` — Next route files: `page.tsx` renders the canvas; `api/flows` + `api/agent` - (the agent bridge), `api/proxy` (+ `multipart`), and `api/share` routes; root `layout.tsx`. +- `app/` — Next route files: `page.tsx` is the marketing landing, `app/page.tsx` renders the + canvas (route `/app`); `login/`, `signup/`, `account/` pages; `api/auth/[...all]` (better-auth), + `api/flows` + `api/agent` (the agent bridge), `api/proxy` (+ `multipart`), and `api/share` + routes; root `layout.tsx`. `src/marketing/` holds the landing's client bits (theme toggle). +- `middleware.ts` — optimistic session-cookie gate; redirects unauthenticated visitors to `/login`. - `src/canvas/` — the client app: - `use-canvas-store.ts` — persisted graphs (nodes/edges/viewport, multiple named canvases). - `use-run-store.ts` — in-memory per-node run state (responses are never persisted). @@ -40,9 +49,12 @@ pnpm start - `parse-curl.ts` / `parse-openapi.ts` — importers behind the import dialog. - `use-agent-sync.ts` — subscribes the browser as the execution host for agent-pushed flows (SSE). - `components/` — request/collection/assert nodes, binding edge + inspector, rail, library, status bar, import dialog. -- `src/server/` — server-side flow layer: +- `src/server/` — server-side layer: + - `auth.ts` — builds better-auth per-request from the D1 binding; `auth-shared.ts` holds the plugin config. + - `require-auth.ts` — bridge guard: session cookie or bearer token → userId, else 401. - `agent-hub.ts` — in-memory hub (flows persisted to `.justapi/flows/*.json`); SSE broadcast + run long-polling. - `run-flow-spec.ts` — headless executor that mirrors the browser engine's semantics and report shape. +- `src/db/schema.ts` — better-auth Drizzle schema (D1); `src/lib/auth-client.ts` — the browser auth client. - `mcp/server.mjs` — stdio MCP server exposing flows as tools (`pnpm mcp`). - `src/stores/use-environment-store.ts` — environments with `{{variable}}` substitution. - `src/utils/` — `http` (proxy fetch), `variables`, `har`, theme plumbing. @@ -76,6 +88,81 @@ runs execute headless server-side with the same report. An MCP server (`pnpm mcp`) exposes the same as native tools for Claude Code and other MCP clients. See [docs/agent-api.md](docs/agent-api.md). +## Accounts & auth + +**Auth is optional.** Anonymous users get the full canvas locally — graphs live +in their browser's localStorage. Signing in unlocks the account-scoped features: +the agent bridge, sharing, token minting, and canvas sync across devices. + +`middleware.ts` only guards `/account`; everything else is open. The bridge +routes (`/api/flows`, `/api/agent/*`, `/api/share/*`) call `requireAuth`, which +accepts **either** the browser session cookie **or** an +`Authorization: Bearer ` — so a signed-out canvas simply doesn't open +them (the agent-bridge SSE only connects when signed in). + +- **Users** sign up at `/signup`, sign in at `/login`. The rail's account icon + shows "Sign in" when signed out, "Account" when signed in. +- **Social login (Google / GitHub)** appears automatically once its credentials + are set — see below. Signed-in users link/unlink providers from `/account`. +- **Tokens** are minted at `/account` — the plaintext is shown once. Use it as + the MCP bridge's `JUSTAPI_TOKEN`. + +### Google & GitHub login + +Each provider turns on only when **both** halves of its credential are present, +so the buttons stay hidden until you configure them. Create an OAuth app with +these callback URLs (dev shown; swap the origin for your deployed URL): + +- Google — Authorized redirect URI: `http://localhost:3100/api/auth/callback/google` +- GitHub — Authorization callback URL: `http://localhost:3100/api/auth/callback/github` + +Then set the credentials. **Dev** (`.dev.vars`, restart `pnpm dev` to pick up): + +``` +GOOGLE_CLIENT_ID=… +GOOGLE_CLIENT_SECRET=… +GITHUB_CLIENT_ID=… +GITHUB_CLIENT_SECRET=… +``` + +**Production** (Cloudflare secrets): + +```bash +wrangler secret put GOOGLE_CLIENT_ID +wrangler secret put GOOGLE_CLIENT_SECRET +wrangler secret put GITHUB_CLIENT_ID +wrangler secret put GITHUB_CLIENT_SECRET +``` + +No migration is needed — the existing `account` table already stores linked +providers. +- Auth is [better-auth](https://better-auth.com): `src/server/auth.ts` builds it + per-request from the D1 binding; `app/api/auth/[...all]` mounts the handler. + Schema lives in `src/db/schema.ts` (regenerate with `pnpm auth:generate`, then + `pnpm db:generate` for the SQL migration). + +The MCP server (`mcp/server.mjs`) sends the token on every call: + +```bash +claude mcp add justapi \ + -e JUSTAPI_URL=http://localhost:3100 \ + -e JUSTAPI_TOKEN= \ + -- node /path/to/justapi/mcp/server.mjs +``` + +## Deploy (Cloudflare) + +```bash +wrangler d1 create justapi # paste database_id into wrangler.jsonc +wrangler r2 bucket create justapi-shares +pnpm db:migrate # apply schema to remote D1 +wrangler secret put BETTER_AUTH_SECRET +wrangler secret put BETTER_AUTH_URL # your deployed origin +pnpm deploy # opennextjs build + deploy +``` + +`pnpm preview` runs the built Worker locally (miniflare) for a production-like check. + ## Outgoing requests The browser calls `/api/proxy`, which forwards to the target URL server-side. @@ -83,7 +170,25 @@ This sidesteps CORS for arbitrary endpoints. ## Persistence -Graphs (nodes, edges, viewport) persist to localStorage (`justapi-canvas`). -Responses are kept in memory only. Share links (`/?s=ID`) resolve via -`/api/share` (Vercel Blob) and spawn a request node; legacy -`/playground?s=ID` links redirect here. +Graphs persist to localStorage (`justapi-canvas`) for a local-first, works-signed-out +experience. **Signed-in, canvases + environments also sync to D1 per user** +(`src/canvas/use-canvas-sync.ts`): the server is the source of truth on load, a +canvas the server lacks is either uploaded (never-synced local work) or dropped +(deleted on another device — tracked via a local `justapi-synced-ids` set so a +delete doesn't resurrect). Responses are kept in memory only. Accounts, sessions, +and API tokens persist to **D1**. Share links (`/app?s=ID`) resolve via +`/api/share` (**R2**) and spawn a request node; legacy `/?s=ID` and +`/playground?s=ID` links redirect to the canvas at `/app`. + +App tables (`canvas`, `environment`) live in `src/db/app-schema.ts` — **separate +from `src/db/schema.ts`**, which `pnpm auth:generate` overwrites. After changing +either, run `pnpm db:generate` then `pnpm db:migrate:local` (`--remote` for prod). + +### Plans & limits + +`src/server/plan.ts` defines per-plan limits; everyone is on **free (5 canvases)** +until billing exists (`getUserPlan` is the seam to change). The cap blocks +*creating* new canvases past the limit — existing canvases are grandfathered +(bulk `POST /api/canvases/import` bypasses it; per-canvas `PUT` enforces it with a +`402`). The client also gates creation (`createCanvasGuarded`) and the account +page shows usage as `N / limit`. diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..9a1e41b --- /dev/null +++ b/app/account/page.tsx @@ -0,0 +1,10 @@ +import { AccountView } from "@/src/account/account-view"; +import { enabledSocialProviders } from "@/src/server/social-providers"; + +export const metadata = { title: "Account" }; +export const dynamic = "force-dynamic"; + +export default async function AccountPage() { + const providers = await enabledSocialProviders(); + return ; +} diff --git a/app/api/agent/events/route.ts b/app/api/agent/events/route.ts index c540018..8b18eab 100644 --- a/app/api/agent/events/route.ts +++ b/app/api/agent/events/route.ts @@ -1,9 +1,14 @@ import { agentHub } from "@/src/server/agent-hub"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; export const dynamic = "force-dynamic"; -/** SSE stream the open canvas subscribes to for agent-pushed work. */ -export async function GET() { +/** SSE stream the open canvas subscribes to for agent-pushed work. + * Cookie-authenticated only — EventSource can't set a bearer header, and + * the only subscriber is the browser canvas, which carries the session. */ +export async function GET(request: Request) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; const encoder = new TextEncoder(); let clientRef: { send: (e: string, d: unknown) => void; close: () => void }; diff --git a/app/api/agent/results/route.ts b/app/api/agent/results/route.ts index e450f5b..6d7e6bc 100644 --- a/app/api/agent/results/route.ts +++ b/app/api/agent/results/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { agentHub } from "@/src/server/agent-hub"; import type { FlowRunReport } from "@/src/canvas/flow-spec"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; export const dynamic = "force-dynamic"; /** The canvas posts flow run reports here; pending agent runs resolve. */ export async function POST(request: NextRequest) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; let body: { slug?: string; report?: FlowRunReport }; try { body = await request.json(); diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..93baae9 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,8 @@ +import { getAuth } from "@/src/server/auth"; + +async function handler(request: Request) { + const auth = await getAuth(); + return auth.handler(request); +} + +export { handler as GET, handler as POST }; diff --git a/app/api/canvases/[id]/route.ts b/app/api/canvases/[id]/route.ts new file mode 100644 index 0000000..19d16ab --- /dev/null +++ b/app/api/canvases/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; +import { upsertCanvas, deleteCanvas } from "@/src/server/canvas-store"; +import { getUserPlan, limitsFor } from "@/src/server/plan"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; + const { id } = await params; + + let body: { name?: string; data?: string }; + try { + body = (await request.json()) as { name?: string; data?: string }; + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }); + } + if (typeof body.data !== "string") { + return NextResponse.json({ error: "data required" }, { status: 400 }); + } + + const outcome = await upsertCanvas( + auth.userId, + { id, name: body.name ?? "", data: body.data }, + true, + ); + if (outcome === "forbidden") { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + if (outcome === "limit") { + const limit = limitsFor(getUserPlan(auth.userId)).canvases; + return NextResponse.json( + { error: "canvas limit reached", limit }, + { status: 402 }, + ); + } + return NextResponse.json({ ok: true, outcome }); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; + const { id } = await params; + await deleteCanvas(auth.userId, id); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/canvases/import/route.ts b/app/api/canvases/import/route.ts new file mode 100644 index 0000000..197c3a3 --- /dev/null +++ b/app/api/canvases/import/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; +import { + upsertCanvas, + upsertEnvironment, + type CanvasInput, + type EnvironmentInput, +} from "@/src/server/canvas-store"; + +export const dynamic = "force-dynamic"; + +/** + * Bulk claim canvases + environments into the account. Cap is intentionally + * bypassed — this grandfathers existing/offline work on first sync, so signing + * in never rejects or loses local canvases. New-canvas creation is gated by the + * per-canvas PUT instead. + */ +export async function POST(request: NextRequest) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; + + let body: { canvases?: CanvasInput[]; environments?: EnvironmentInput[] }; + try { + body = (await request.json()) as { + canvases?: CanvasInput[]; + environments?: EnvironmentInput[]; + }; + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }); + } + + for (const e of body.environments ?? []) { + if (e && typeof e.id === "string" && typeof e.variables === "string") { + await upsertEnvironment(auth.userId, { + id: e.id, + name: e.name ?? "", + variables: e.variables, + }); + } + } + for (const c of body.canvases ?? []) { + if (c && typeof c.id === "string" && typeof c.data === "string") { + await upsertCanvas( + auth.userId, + { id: c.id, name: c.name ?? "", data: c.data }, + false, + ); + } + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/canvases/route.ts b/app/api/canvases/route.ts new file mode 100644 index 0000000..db341ff --- /dev/null +++ b/app/api/canvases/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; +import { listForUser, usageFrom } from "@/src/server/canvas-store"; +import { getUserPlan, limitsFor } from "@/src/server/plan"; + +export const dynamic = "force-dynamic"; + +/** Pull the signed-in user's whole workspace: canvases + environments, plus + * usage/limits/plan for the account page and the client-side create gate. */ +export async function GET(request: NextRequest) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; + + const { canvases, environments } = await listForUser(auth.userId); + const plan = getUserPlan(auth.userId); + + return NextResponse.json({ + canvases: canvases.map((c) => ({ + id: c.id, + name: c.name, + data: c.data, + updatedAt: c.updatedAt.getTime(), + })), + environments: environments.map((e) => ({ + id: e.id, + name: e.name, + variables: e.variables, + updatedAt: e.updatedAt.getTime(), + })), + usage: usageFrom(canvases, environments.length), + limits: limitsFor(plan), + plan, + }); +} diff --git a/app/api/environments/[id]/route.ts b/app/api/environments/[id]/route.ts new file mode 100644 index 0000000..337241a --- /dev/null +++ b/app/api/environments/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; +import { upsertEnvironment, deleteEnvironment } from "@/src/server/canvas-store"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; + const { id } = await params; + + let body: { name?: string; variables?: string }; + try { + body = (await request.json()) as { name?: string; variables?: string }; + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }); + } + if (typeof body.variables !== "string") { + return NextResponse.json({ error: "variables required" }, { status: 400 }); + } + + const outcome = await upsertEnvironment(auth.userId, { + id, + name: body.name ?? "", + variables: body.variables, + }); + if (outcome === "forbidden") { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + return NextResponse.json({ ok: true }); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; + const { id } = await params; + await deleteEnvironment(auth.userId, id); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/flows/[slug]/route.ts b/app/api/flows/[slug]/route.ts index 15de3d8..878687b 100644 --- a/app/api/flows/[slug]/route.ts +++ b/app/api/flows/[slug]/route.ts @@ -1,13 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { agentHub } from "@/src/server/agent-hub"; import { parseFlowSpec } from "@/src/canvas/flow-spec"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; export const dynamic = "force-dynamic"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ slug: string }> } ) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; const { slug } = await params; const spec = agentHub.get(slug); if (!spec) { @@ -23,6 +26,8 @@ export async function PUT( request: NextRequest, { params }: { params: Promise<{ slug: string }> } ) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; await params; // slug is derived from spec.name — URL slug is advisory let body: unknown; try { diff --git a/app/api/flows/[slug]/run/route.ts b/app/api/flows/[slug]/run/route.ts index c9a693e..466420d 100644 --- a/app/api/flows/[slug]/run/route.ts +++ b/app/api/flows/[slug]/run/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { agentHub } from "@/src/server/agent-hub"; import { runFlowSpecHeadless } from "@/src/server/run-flow-spec"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; export const dynamic = "force-dynamic"; @@ -18,6 +19,8 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ slug: string }> } ) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; const { slug } = await params; const spec = agentHub.get(slug); if (!spec) { diff --git a/app/api/flows/route.ts b/app/api/flows/route.ts index b01e185..70f64ee 100644 --- a/app/api/flows/route.ts +++ b/app/api/flows/route.ts @@ -1,16 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import { agentHub } from "@/src/server/agent-hub"; import { parseFlowSpec } from "@/src/canvas/flow-spec"; +import { requireAuth, isAuthError } from "@/src/server/require-auth"; export const dynamic = "force-dynamic"; /** List known flows. */ -export async function GET() { +export async function GET(request: NextRequest) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; return NextResponse.json({ flows: agentHub.list() }); } /** Create/update a flow from its spec (slug derives from spec.name). */ export async function POST(request: NextRequest) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; let body: unknown; try { body = await request.json(); diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts index 6ed8480..027adf5 100644 --- a/app/api/proxy/route.ts +++ b/app/api/proxy/route.ts @@ -24,7 +24,14 @@ function normalizeLocalhost(rawUrl: string): { export async function POST(request: NextRequest) { const startTime = Date.now(); try { - const body = await request.json(); + const body = (await request.json()) as { + url?: string; + method?: string; + headers?: Record; + body?: string; + params?: Record; + isFormData?: boolean; + }; const { url, method, @@ -101,15 +108,27 @@ export async function POST(request: NextRequest) { : []; const contentType = response.headers.get("content-type") || ""; + const ct = contentType.toLowerCase(); let data: unknown; - if (contentType.includes("application/json")) { + if (ct.includes("application/json") || ct.includes("+json")) { try { data = await response.json(); } catch { data = await response.text(); } - } else if (contentType.includes("text/")) { + } else if ( + ct.includes("text/") || + ct.includes("javascript") || + ct.includes("ecmascript") || + ct.includes("xml") || + ct.includes("yaml") || + ct.includes("csv") || + ct.includes("charset") + ) { + // Includes application/javascript (Swagger UI's swagger-ui-init.js embeds + // the spec) and other text-ish payloads that would otherwise be lost as a + // {}-serialized ArrayBuffer. data = await response.text(); } else { const blob = await response.blob(); diff --git a/app/api/share/[id]/route.ts b/app/api/share/[id]/route.ts index d9ad177..13a7d03 100644 --- a/app/api/share/[id]/route.ts +++ b/app/api/share/[id]/route.ts @@ -1,15 +1,20 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; import { get, isValidId } from '../store'; +import { requireAuth, isAuthError } from '@/src/server/require-auth'; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; const { id } = await params; if (!isValidId(id)) { return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); } - const data = await get(id); + const { env } = await getCloudflareContext({ async: true }); + const data = await get(env.SHARE_BUCKET, id); if (data === null) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } diff --git a/app/api/share/route.ts b/app/api/share/route.ts index 21a1845..339865c 100644 --- a/app/api/share/route.ts +++ b/app/api/share/route.ts @@ -1,9 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; import { generateId, put } from './store'; +import { requireAuth, isAuthError } from '@/src/server/require-auth'; const MAX_BYTES = 50_000; export async function POST(request: NextRequest) { + const auth = await requireAuth(request); + if (isAuthError(auth)) return auth; const body = await request.text(); if (body.length === 0 || body.length > MAX_BYTES) { return NextResponse.json( @@ -19,7 +23,8 @@ export async function POST(request: NextRequest) { const id = generateId(); try { - await put(id, body); + const { env } = await getCloudflareContext({ async: true }); + await put(env.SHARE_BUCKET, id, body); } catch (err) { const message = err instanceof Error ? err.message : 'Storage failed'; return NextResponse.json({ error: message }, { status: 500 }); diff --git a/app/api/share/store.ts b/app/api/share/store.ts index 560107c..336fd7a 100644 --- a/app/api/share/store.ts +++ b/app/api/share/store.ts @@ -1,27 +1,27 @@ import 'server-only'; -import { put as blobPut, head, BlobNotFoundError } from '@vercel/blob'; -const blobPath = (id: string) => `shares/${id}.json`; +const objectKey = (id: string) => `shares/${id}.json`; -export async function put(id: string, data: string): Promise { - await blobPut(blobPath(id), data, { - access: 'public', - addRandomSuffix: false, - contentType: 'application/json', - cacheControlMaxAge: 31536000, +export async function put( + bucket: R2Bucket, + id: string, + data: string +): Promise { + await bucket.put(objectKey(id), data, { + httpMetadata: { + contentType: 'application/json', + cacheControl: 'public, max-age=31536000, immutable', + }, }); } -export async function get(id: string): Promise { - try { - const blob = await head(blobPath(id)); - const res = await fetch(blob.url); - if (!res.ok) return null; - return await res.text(); - } catch (err) { - if (err instanceof BlobNotFoundError) return null; - throw err; - } +export async function get( + bucket: R2Bucket, + id: string +): Promise { + const obj = await bucket.get(objectKey(id)); + if (!obj) return null; + return await obj.text(); } const ALPHABET = diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..a64b928 --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { CanvasClient } from "@/src/canvas/components/canvas-client"; + +export const metadata: Metadata = { + title: "Canvas", + description: + "Drop requests on a canvas, chain response values into the next request, and run whole flows.", + alternates: { canonical: "/app" }, +}; + +export default function CanvasPage() { + return ; +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..452f38f --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,15 @@ +import { Suspense } from "react"; +import { AuthForm } from "@/src/auth/auth-form"; +import { enabledSocialProviders } from "@/src/server/social-providers"; + +export const metadata = { title: "Sign in" }; +export const dynamic = "force-dynamic"; + +export default async function LoginPage() { + const providers = await enabledSocialProviders(); + return ( + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 85c99fa..c653e94 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,19 +1,239 @@ import type { Metadata } from "next"; -import { CanvasClient } from "@/src/canvas/components/canvas-client"; +import Link from "next/link"; +import { ThemeToggle } from "@/src/marketing/theme-toggle"; + +const DESCRIPTION = + "Drop requests on a canvas, wire a response value into the next call, and run the whole chain. Import cURL, fetch, HAR, or OpenAPI — and let an agent drive it."; export const metadata: Metadata = { - title: "JustAPI — node-based API explorer", - description: - "Drop requests on a canvas, chain response values into the next request, and run whole flows. Import cURL, fetch, HAR, or OpenAPI.", + title: "JustAPI — an API client that thinks in flows", + description: DESCRIPTION, alternates: { canonical: "/" }, - openGraph: { - title: "JustAPI — node-based API explorer", - description: - "Drop requests on a canvas, chain response values into the next request, and run whole flows.", - url: "/", - }, + openGraph: { title: "JustAPI — an API client that thinks in flows", description: DESCRIPTION, url: "/" }, }; -export default function HomePage() { - return ; +const Brand = ({ size = "md" }: { size?: "md" | "sm" }) => ( + + + {"{}"} + + + just + api + + +); + +const IDEAS = [ + { + n: "01", + title: "Chain, don't copy-paste", + body: "Wire a value from one response into the next request. Bindings resolve in dependency order, and captures pull tokens into variables automatically.", + }, + { + n: "02", + title: "Run the whole flow", + body: "One click executes every request in order and grades your asserts as the responses land — “4 passed · 2 checks ✓”. No tab-hopping.", + }, + { + n: "03", + title: "Agents drive it too", + body: "Push a declarative flow over MCP and watch it materialize and run on the board, then read back a machine verdict. You supervise; the agent proves the API.", + }, +]; + +export default function LandingPage() { + return ( +
+ {/* NAV */} + + + {/* HERO */} +
+
+ POSTMAN, AS A GRAPH +
+

+ Test APIs as a graph, +
+ not a folder of tabs. +

+

+ {DESCRIPTION} +

+
+ + Open the canvas + + + sign in to sync + +
+
+ + {/* LIVE CANVAS EMBED */} +
+
+