Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5a4d906
Add accounts and mintable API tokens on Cloudflare D1
codellyson Jul 31, 2026
e78b652
Make auth optional — anonymous local use, sign-in for remote features
codellyson Jul 31, 2026
cbdf0b7
Make /login double as register with an in-page toggle
codellyson Jul 31, 2026
5fa1bff
Add a landing page at / ; move the canvas to /app
codellyson Jul 31, 2026
5dc6485
Make the canvas a read-only preview when embedded in an iframe
codellyson Aug 1, 2026
da07980
Hide all canvas chrome in the iframe embed
codellyson Aug 1, 2026
46f2fea
Seed the iframe embed with a curated demo flow
codellyson Aug 1, 2026
8fd9069
Auto-arrange and frame the embed demo on mount
codellyson Aug 1, 2026
d3032f0
Frame the active graph when it changes on the standalone canvas
codellyson Aug 1, 2026
ab50db5
Remove the redundant "driven by You/Agent" toggle
codellyson Aug 1, 2026
857aee6
Pan the camera to follow the running node during a flow run
codellyson Aug 1, 2026
b79e406
Follow the run camera: pan to the origin, then node-by-node
codellyson Aug 1, 2026
bb01bea
Color the run summary by outcome, not always red
codellyson Aug 1, 2026
ca8e831
Add a play overlay that runs the demo in the embed
codellyson Aug 1, 2026
32489e5
Make OpenAPI import actually usable (Tier 1)
codellyson Aug 1, 2026
147c1d9
Import OpenAPI/Swagger specs: URL fetch, rich parse, tag filtering
codellyson Aug 1, 2026
ef956c3
Templatize imported spec URLs as {{base}} + create the env on import
codellyson Aug 2, 2026
a0804f1
Make the import box compact + auto-grow
codellyson Aug 2, 2026
6e3cf98
Close the import modal only via ✕ or Escape, not backdrop click
codellyson Aug 2, 2026
970328c
Import a spec onto its own canvas with a pinned env, not merged into …
codellyson Aug 2, 2026
7310e5f
Default big imports to their first resource group; show each canvas's…
codellyson Aug 2, 2026
b637806
Add Google & GitHub login; enrich the account page
codellyson Aug 2, 2026
bcc6a8d
Show a workspace usage overview on the account page
codellyson Aug 2, 2026
eb1b84b
Sync canvases + environments to D1 per user; enforce Free tier (5 can…
codellyson Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
125 changes: 115 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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.
Expand Down Expand Up @@ -76,14 +88,107 @@ 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 <token>` — 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=<minted-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.
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`.
10 changes: 10 additions & 0 deletions app/account/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <AccountView providers={providers} />;
}
9 changes: 7 additions & 2 deletions app/api/agent/events/route.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down
3 changes: 3 additions & 0 deletions app/api/agent/results/route.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
8 changes: 8 additions & 0 deletions app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
53 changes: 53 additions & 0 deletions app/api/canvases/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
52 changes: 52 additions & 0 deletions app/api/canvases/import/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
34 changes: 34 additions & 0 deletions app/api/canvases/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading