Skip to content

Repository files navigation

Magic Forms

A multi-tenant form platform: every company gets a workspace, every workspace gets a multi-step form builder with 23 field types, and every form gets a public link, a submissions store, a REST API and webhooks on each event.

  • Frontend — Next.js 16 (App Router) + shadcn/ui (base-nova style, built on Base UI)
  • Backend — Convex only: database, queries/mutations/actions, file storage, scheduling, HTTP endpoints and authentication

Getting started

bun install
npx convex dev      # deploys convex/ and watches for changes
bun dev             # http://localhost:3000

.env.local needs NEXT_PUBLIC_CONVEX_URL and NEXT_PUBLIC_CONVEX_SITE_URL.

First-time deployment setup

Magic Forms signs its own access tokens, so each deployment needs a signing key. One command generates the keypair and stores it — there is nothing to copy by hand:

node scripts/setup-auth-keys.mjs

It sets JWT_PRIVATE_KEY, JWKS and JWT_KID on the deployment npx convex is currently pointed at, refuses to overwrite keys that already look valid, and reads them back to confirm. Pass --force to rotate, which signs everyone out.

If sign-in fails with Failed to execute 'atob', JWT_PRIVATE_KEY is not valid base64 — run the script again to repair it.

Create the platform administrator (never exposed to clients):

npx convex run auth:createAdmin '{"email":"you@example.com","name":"Your Name","password":"a-strong-password"}'

Deploying

Convex (production)

Dev and prod are separate databases with separate keys, so production needs the same two setup steps once:

npx convex deploy                       # push functions, schema and indexes
node scripts/setup-auth-keys.mjs --prod # signing keys for THIS deployment
npx convex run auth:createAdmin '{"email":"…","name":"…","password":"…"}' --prod

Without the keys, every sign-in on production fails with Failed to execute 'atob' — a token signed for dev is meaningless to prod.

Vercel

Point the project at this directory and override the build command so Convex deploys in the same step and hands the build its URL:

Build command:  npx convex deploy --cmd 'next build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL
Install:        (leave as detected)
Output:         (leave as detected)

Environment variables:

Name Value Notes
CONVEX_DEPLOY_KEY production deploy key Convex dashboard → Settings → Deploy keys. This is what targets prod; keep it secret
NEXT_PUBLIC_CONVEX_SITE_URL https://<deployment>.convex.site The .site domain, not .cloud. Nothing injects this — it backs the API endpoints shown in the UI
NEXT_PUBLIC_SITE_URL https://<your-domain> The app's own public origin. Only used as metadataBase, but without it og:image resolves against localhost and link previews break

NEXT_PUBLIC_CONVEX_URL is supplied by convex deploy --cmd, so do not set it by hand. Do not set CONVEX_DEPLOYMENT on Vercel — that variable selects your dev deployment and is for local use only.

Both NEXT_PUBLIC_* values are inlined at build time, so changing either one needs a redeploy, not just a restart.

For preview deployments, add a preview deploy key instead and give each preview its own signing keys with node scripts/setup-auth-keys.mjs --preview-name <branch>.

How authentication works

There is no third-party auth provider. Convex is the identity provider:

  1. auth.signIn verifies a PBKDF2-SHA256 password hash and mints a 30-minute RS256 access token plus a 30-day refresh token (stored only as a SHA-256 hash).
  2. convex/http.ts publishes /.well-known/openid-configuration and /.well-known/jwks.json.
  3. convex/auth.config.ts points Convex at its own site URL, so ctx.auth.getUserIdentity() validates those tokens natively.
  4. The browser holds the refresh token and ConvexProviderWithAuth exchanges it for access tokens as they expire.

No function ever takes a user id as an argument for authorization; every check derives the caller from ctx.auth.

Roles

Scope Role Can do
Platform admin Sees every account, workspace and agent token; can change roles, disable accounts, archive workspaces and revoke any MCP token
Workspace owner Everything, including deleting the workspace
Workspace admin Members, webhooks, API keys, forms, responses
Workspace editor Builds forms, manages responses
Workspace viewer Read-only

Routes

Route Purpose
/ Landing page
/sign-in, /sign-up Authentication
/app Redirects into your first workspace
/app/w/{workspaceId} Workspace overview
/app/w/{workspaceId}/forms Form list
/app/w/{workspaceId}/forms/{formId} Builder — Build / Preview / Settings / Share
/app/w/{workspaceId}/forms/{formId}/responses One form's responses + CSV export
/app/w/{workspaceId}/responses Every response in the workspace
/app/w/{workspaceId}/webhooks Endpoints and the delivery log
/app/w/{workspaceId}/api API keys and endpoint reference
/app/w/{workspaceId}/members Members and roles
/app/w/{workspaceId}/settings Workspace settings
/app/admin Platform admin console — stats and charts
/app/admin/accounts Every account, with roles and enable/disable
/app/admin/workspaces Every workspace, with archive-and-purge
/app/admin/mcp Your platform MCP endpoint + every token on the platform
/w/{workspaceSlug} Public — every published form in a workspace
/f/{workspaceSlug}/{formSlug} Public — a single form

Field types

Text · Long answer · Email · Phone · URL · Password · Number · Date · Time · Dropdown · Multi-select · Radio group · Checkbox group · Single checkbox · Switch · Slider · Star rating · One-time code · File upload · Hidden · Heading · Paragraph · Divider

Each field is full, half or third width and collapses to one column on a phone. Validation (required, min/max, length, regex, file size and type) runs in the renderer and again in Convex, so the HTTP API cannot bypass it.

REST API

Base URL is NEXT_PUBLIC_CONVEX_SITE_URL. CORS is open on all three endpoints.

# Published forms in a workspace
GET /api/v1/forms/{workspaceSlug}

# One form's steps, fields, options and validation rules
GET /api/v1/forms/{workspaceSlug}/{formSlug}

# Create a submission — values may be strings, numbers, booleans or arrays
POST /api/v1/submit/{workspaceSlug}/{formSlug}
     {"full_name": "Ada Lovelace", "use_cases": ["onboarding"]}
     -> 201 | 422 with {issues:[{key,message}]} | 404

# Read stored responses (requires a workspace API key)
GET /api/v1/submissions?form={formSlug}&limit=50
    Authorization: Bearer mf_live_...

API keys are shown once and stored only as a SHA-256 digest.

MCP server

mcp/server.mjs exposes Magic Forms to AI agents over the Model Context Protocol, so an agent can provision a company, stand up a workspace, build and publish a form, manage members and read responses without touching the UI.

It signs in as one ordinary Magic Forms account and holds that session, so the tools are bounded by exactly the same authorization as the web app — nothing is special-cased. A server signed in as a workspace editor cannot invite members; one signed in as staff gets the platform console. Which tools exist follows from who it signed in as: an account with the platform admin role also gets the admin_* tools. That is a convenience for the agent, not the security boundary — Convex re-checks the role on every call.

Hosted endpoint

Create one under API keys → AI agents in any workspace, or — as platform staff — under Admin console → MCP, which mints one belonging to no workspace at all and carrying the admin_* tools. You get a URL:

https://forms.yourco.com/api/mcp/mf_mcp_...

Point any MCP client that speaks HTTP at it — nothing to install. The token is shown once, acts as the account that created it, and is revoked from the same screen. Authorization: Bearer <token> is honoured too, and is the better choice where a client supports headers, since URLs end up in logs.

Redeeming a token mints the same short-lived access token a sign-in does, so every call underneath still goes through requireWorkspaceAccess. The redeemed credential is cached for up to a minute, which is also the longest a revoked token can keep working.

Admin console → MCP lists every token on the platform — who it acts as, which workspace it was created in (or "Platform" for a staff one), when it last called, and whether it is still live — so standing agent access can be seen and cut off in one place.

Running it yourself

The same tools ship as a stdio server for anyone who would rather not hand a hosted endpoint their workspace. Put the account it should act as in .env.local, which is git-ignored:

MAGIC_FORMS_EMAIL=agent@yourcompany.com
MAGIC_FORMS_PASSWORD=...
MAGIC_FORMS_APP_URL=http://localhost:3000   # optional, for the links it returns

Real environment variables win over the file, so a deployed agent needs no .env.local at all. Check what that account can do:

node mcp/server.mjs --list

.mcp.json in the repo root already registers it for Claude Code and anything else reading that format. Clients wanting explicit config want node mcp/server.mjs; the server finds .env.local from its own location, so the working directory does not matter.

Tools

Group Tools
Workspaces whoami, list_workspaces, create_workspace, get_workspace, update_workspace, archive_workspace
Members list_members, add_member, update_member_role, remove_member
Forms build_form, list_forms, get_form, create_form, update_form, set_form_status, duplicate_form, delete_form
Steps and fields add_step, add_field, update_field, remove_field
Responses list_responses, export_responses_csv
Integrations list_webhooks, create_webhook, update_webhook, test_webhook, list_webhook_deliveries, remove_webhook, list_api_keys, create_api_key, revoke_api_key
Platform admin admin_overview, admin_list_users, admin_list_workspaces, admin_create_company, admin_create_workspace_for, admin_set_user_role, admin_set_user_disabled, admin_delete_workspace

build_form is the one to reach for first: it takes a whole form — steps, fields, options, validation and settings — and returns a published URL in a single call.

{
  "workspaceId": "...",
  "title": "Supplier onboarding",
  "publish": true,
  "steps": [
    {
      "title": "Your company",
      "fields": [
        { "type": "text", "label": "Company name", "key": "company_name", "required": true, "width": "half" },
        { "type": "email", "label": "Contact email", "key": "contact_email", "required": true, "width": "half" },
        { "type": "select", "label": "Category", "key": "category",
          "options": [{ "label": "Produce", "value": "produce" }] }
      ]
    }
  ]
}

admin_create_company is the other half: it creates the account, the workspace it owns and the owner membership together, returning a temporary password once when you do not supply one. It is backed by two Convex functions added for it — admin:createCompany and admin:createWorkspaceFor — because the admin console could previously only read and moderate, never provision.

Webhooks

Ten events: form.created, form.updated, form.published, form.unpublished, form.deleted, form.viewed, form.step_completed, submission.created, submission.updated, submission.deleted.

A webhook is scoped to the whole workspace, or to a single form. Deliveries are scheduled (never blocking the mutation), retried twice on a network error or 5xx with a 15s then 60s backoff, and logged with status code, response body and duration.

Every request carries:

x-magicforms-event      submission.created
x-magicforms-delivery   <delivery id>
x-magicforms-timestamp  <unix seconds>
x-magicforms-signature  sha256=<hmac>

where the HMAC is HMAC-SHA256(secret, timestamp + "." + rawBody).

import { createHmac, timingSafeEqual } from "node:crypto";

const expected =
  "sha256=" +
  createHmac("sha256", secret)
    .update(req.headers["x-magicforms-timestamp"] + "." + rawBody)
    .digest("hex");

timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers["x-magicforms-signature"]));

Backend layout

convex/
  schema.ts          tables, field types, webhook events
  auth.ts            sign up / in / out, token refresh, createAdmin
  auth.config.ts     points Convex at its own JWKS
  http.ts            JWKS + OIDC discovery, and the public REST API
  workspaces.ts      workspaces, members, roles, stats
  forms.ts           forms, steps, fields, ordering
  submissions.ts     responses, CSV export, read state
  webhooks.ts        endpoints, signed delivery with retries, delivery log
  apiKeys.ts         hashed keys
  admin.ts           platform console, and company provisioning
  mcpTokens.ts       tokens for the hosted MCP endpoint
  publicForms.ts     unauthenticated form rendering and submission
  api.ts             query/mutation backends for the REST endpoints
  cleanup.ts         batched cascade deletes, scheduled pruning
  crons.ts           six-hourly housekeeping
  lib/
    crypto.ts        PBKDF2, SHA-256, HMAC, RS256 JWT signing (actions only)
    authz.ts         requireUser / requireWorkspaceAccess / requireFormAccess
    events.ts        webhook fan-out
    validate.ts      server-side submission validation

The tool definitions are shared: the stdio server and the hosted endpoint at app/api/mcp/[token]/route.ts both serve the same mcp/tools/* modules, and differ only in how they authenticate.

mcp/
  server.mjs         stdio MCP server: signs in, gates tools by role, serves them
  convex.mjs         the signed-in Convex session the stdio server runs on
  api.mjs            Convex function references, shared by both transports
  schema.mjs         JSON Schema builders and the shared enums
  tools/
    workspace.mjs    whoami, workspaces, members
    forms.mjs        build_form, forms, steps, fields, responses
    integrations.mjs webhooks and API keys
    admin.mjs        platform staff tools

UI

Everything is composed from components/ui/* (shadcn) — no bespoke UI primitives. The three app-level compositions are components/app-sidebar.tsx (the workspace sidebar), components/field-control.tsx (renders one field of any type) and components/form-renderer.tsx (the multi-step form used by both the public page and the builder preview).

Brand assets

public/images/logo.png is the only hand-maintained image. Every favicon, app icon and social card is derived from it:

npm run brand:assets      # node scripts/generate-brand-assets.mjs

That writes app/favicon.ico (16/32/48 — the 16px slice is cropped to just the document, because the full mark turns to mush at that size), app/icon.png, app/apple-icon.png, app/opengraph-image.png, app/twitter-image.png, the public/icons/* sizes that app/manifest.ts points at, and public/images/logo-mark.png. Do not hand-edit those — replace logo.png and re-run.

In the product, always render components/logo.tsx rather than an icon or a raw <img>; it is the one place the mark's sizing and loading behaviour lives. Next.js picks up favicon.ico, icon.png and apple-icon.png from app/ by file convention, so metadata.icons is deliberately not set in app/layout.tsx — setting it suppresses those conventions.

Releases

Packages

Contributors

Languages