Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions compute/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Composer.
| [`nextjs`](./nextjs) | Next.js App Router app using standalone output for Prisma Compute. |
| [`tanstack-start`](./tanstack-start) | TanStack Start app using the Nitro Vite plugin output supported by Prisma Compute. |
| [`personal-site`](./personal-site) | Astro personal site with no database. |
| [`form-backend`](./form-backend) | Self-hosted form endpoint (Formspree-style) with a dashboard and CSV export, built with Hono. |

Each example includes a Composer module and a GitHub Actions deployment
workflow. The database-backed examples also include a Prisma 8 contract,
Expand All @@ -24,3 +25,7 @@ same version as the `@prisma/composer` libraries provides the local
fallback — so deploys run the exact Composer version each app depends on.
These pins can move to stable releases once Prisma 8 reaches general
availability.

`form-backend` is a full application rather than a framework starter: it is the
one template that ships a UI, an admin dashboard, and a public write endpoint,
so it doubles as a reference for shaping a real app around a Prisma contract.
10 changes: 10 additions & 0 deletions compute/form-backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy this file to `.env` for the plain `bun run dev` path (no Composer).
# `bun run dev:composer` provisions its own local database and does not need
# DATABASE_URL.

# Connection string for PostgreSQL. Requires PostgreSQL >= 15.
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

# Password for the /admin dashboard. With no value set, the dashboard is
# disabled — there is no default password.
ADMIN_PASSWORD=""
2 changes: 2 additions & 0 deletions compute/form-backend/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
src/prisma/contract.json linguist-generated
src/prisma/contract.d.ts linguist-generated
42 changes: 42 additions & 0 deletions compute/form-backend/.github/workflows/prisma-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: prisma-deploy

on:
push:
delete:

concurrency:
group: prisma-deploy-${{ github.event_name == 'delete' && github.event.ref || github.ref_name }}
cancel-in-progress: false

permissions:
contents: read
id-token: write
pull-requests: write

jobs:
deploy:
if: github.event_name == 'push' && github.ref_type == 'branch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: prisma/cloud-deploy-action@v1
with:
build-command: bun run build

teardown:
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: prisma/cloud-deploy-action@v1
with:
mode: destroy
stage: ${{ github.event.ref }}
12 changes: 12 additions & 0 deletions compute/form-backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules/
dist/

.env*
!.env.example

.prisma/
.prisma-composer/
.alchemy/

*.log
.DS_Store
254 changes: 254 additions & 0 deletions compute/form-backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# Form Backend

A self-hostable form endpoint. Deploy it once, create a form in the dashboard,
then point any static site's `<form action="...">` at the endpoint it gives you.
Submissions land in your database, browsable and exportable as CSV.

No JavaScript required on the sending page. No form-processing vendor in the
middle — submissions go straight into your own Prisma Postgres database.

Built with [Hono](https://hono.dev), [Prisma ORM](https://www.prisma.io), and
[Prisma Composer](https://www.prisma.io) on Prisma Compute + Prisma Postgres.

## Features

- **One endpoint per form** — `POST /f/:slug`, accepting `application/json`,
`application/x-www-form-urlencoded`, and `multipart/form-data` (text fields).
- **Works without JS** — plain HTML posts get a `303` to your redirect URL, or a
built-in thank-you page.
- **Open CORS on `/f/*` only** — browser `fetch()` from any origin works; the
dashboard stays same-origin.
- **Built-in honeypot** — a non-empty `_gotcha` field looks successful and
stores nothing.
- **Server-rendered dashboard** — list forms with submission counts, create,
pause, and delete them; browse submissions 25 at a time; delete individual
entries.
- **CSV export** — union of every field name across a form's submissions, with
spreadsheet formula injection defused.
- **Privacy by default** — no IP address, no user agent. Just the submitted
fields, the `Referer`, and a timestamp.
- **Small footprint** — one embedded stylesheet, no client framework, no CSS
framework, no CSV library.

## Quickstart

```bash
bun install
ADMIN_PASSWORD=choose-a-password bun run dev:composer
```

That builds the app and brings the whole stack up locally — a Prisma Postgres
instance, migrations, and the service — then prints the local URL. Open it and
you'll find a seeded `contact` form with three demo submissions, so the
dashboard and the CSV export have something to show immediately.

The dashboard lives at `/admin`. Sign in with the password you exported.

> `bun run dev:composer` captures `ADMIN_PASSWORD` from your shell on the first
> run and remembers it in `.prisma-composer/dev/`. To change it later, run
> `bun run composer:dev -- --fresh` (this also wipes local data). Tail the
> running app with `bunx prisma composer log module.ts`.

<details>
<summary>Troubleshooting: <code>A Prisma Dev server with the name "pcdev-form-backend-database" is already running</code></summary>

The local emulators (`compute-main.mjs` / `postgres-main.mjs` on ports 4300 and
4301) are shared across every Composer project on your machine. If one of them
was killed uncleanly, it can leave a stale lock behind. Stop the emulator
processes, remove the stale registry entry, and rerun:

```bash
pkill -f "composer-prisma-cloud/dist/(compute|postgres)-main.mjs"
rm -rf "$HOME/Library/Application Support/prisma-dev-nodejs/pcdev-form-backend-database/"{.lock,server.json}
ADMIN_PASSWORD=choose-a-password bun run dev:composer
```

(On Linux the registry lives under `~/.local/share/prisma-dev-nodejs/` instead.)
The database's data directory (`.pglite`) is left in place.
</details>

### Without Composer

If you'd rather point the app at a PostgreSQL database you already have
(15 or newer):

```bash
cp .env.example .env # fill in DATABASE_URL and ADMIN_PASSWORD
bun run db:init # create the tables from src/prisma/contract.prisma
bun run dev # http://localhost:3000
```

## Usage

Every form gets an endpoint at `/f/<slug>`. Copy-paste this into any static
page — GitHub Pages, a Jekyll site, a plain `index.html`:

```html
<form action="https://your-deployment.example/f/contact" method="POST">
<label>Name <input type="text" name="name" required></label>
<label>Email <input type="email" name="email" required></label>
<label>Message <textarea name="message" required></textarea></label>

<!-- Honeypot: humans never see it, bots fill it in. -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true">

<button type="submit">Send</button>
</form>
```

After a successful post the visitor is sent to the form's **redirect URL** with
a `303` if you configured one, and otherwise sees a small built-in
"Thanks — submission received" page.

To stay on your own page, post JSON instead:

```js
const response = await fetch("https://your-deployment.example/f/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Ada",
email: "ada@example.com",
message: "Hello!",
_gotcha: "", // keep the honeypot; leave it empty
}),
});

const result = await response.json(); // { ok: true }
```

A request is treated as JSON when its `Content-Type` is `application/json`, or
when it sends `Accept: application/json`. Everything else gets the redirect or
the HTML page.

### Endpoint behavior

| Situation | Response |
| --- | --- |
| Success, JSON request | `200` `{ "ok": true }` |
| Success, form post with a redirect URL | `303` to that URL |
| Success, form post without one | `200` thank-you page |
| `_gotcha` non-empty | Success response, **nothing stored** |
| Unknown slug | `404` |
| Form paused | `410` |
| Body over 50 KB | `413` |
| Multipart containing a file part | `415` |
| Any other content type | `415` |
| `OPTIONS` preflight | `204` with permissive CORS headers |

Field names beginning with `_` are control fields and are never stored, so
`_gotcha`, `_subject`, and friends stay out of your data.

## Configuration

| Variable | Required | Purpose |
| --- | --- | --- |
| `ADMIN_PASSWORD` | For the dashboard | Password for `/admin`. **There is no default** — with nothing set, the dashboard shows a "disabled" notice and refuses every login, while `/f/:slug` keeps collecting. |
| `DATABASE_URL` | Only for `bun run dev` | Direct (non-Composer) mode. `dev:composer` and deploys get their database from Composer. |

`ADMIN_PASSWORD` is declared as a secret on the service (`src/service.ts`) and
bound from the environment in `module.ts`:

```ts
// src/service.ts
input: type({ adminPassword: secretString() })

// module.ts
input: { adminPassword: envSecret("ADMIN_PASSWORD") }
```

The session cookie is an HMAC-SHA256 of a fixed subject keyed by that password,
compared in constant time — so rotating the password invalidates every session.

## Deploy

Deploys go to Prisma Compute + Prisma Postgres. Connect this repository to
Prisma Cloud and push the branch — the included GitHub Actions workflow
(`.github/workflows/prisma-deploy.yml`) runs Composer, which provisions the
database, applies the contract, and deploys the service:

```bash
bun run compute:connect
```

Set `ADMIN_PASSWORD` on the service in Prisma Cloud so the dashboard is
reachable; without it the collection endpoint still works and `/admin` stays
disabled. Open the running service with `bun run compute:open`.

To deploy from your machine instead, export a service token, a workspace, and
the admin password, then:

```bash
export PRISMA_SERVICE_TOKEN=...
export PRISMA_WORKSPACE_ID=...
export ADMIN_PASSWORD=choose-a-strong-password

bun run deploy
```

`bun run deploy` builds the esbuild bundle and runs
`prisma composer deploy module.ts`, which provisions the database, applies
migrations, and starts the service. The deploy prints the public URL — that
origin plus `/f/<slug>` is what your `<form action>` points at.

Deploy an isolated environment with a stage, and tear it down the same way:

```bash
bunx prisma composer deploy module.ts --stage preview
bunx prisma composer destroy module.ts --stage preview
```

## Project structure

```text
module.ts Composer app: database + service, ADMIN_PASSWORD binding
prisma.config.ts Prisma CLI config
prisma-composer.config.ts Deploy config (Prisma Cloud target)
src/
service.ts Service declaration: deps, input schema, build
index.ts Boot: binds 0.0.0.0 on service.port()
app.tsx Route mounting, 404 and error pages
auth.ts Admin password resolution + HMAC session cookie
csv.ts Hand-rolled CSV writer with formula-injection defusing
origin.ts Public origin behind a proxy
payload.ts Body parsing, size cap, honeypot, control-field stripping
ui.tsx Layout + the one embedded stylesheet
routes/
home.tsx GET / — public landing page
collect.tsx POST /f/:slug — the collection endpoint (+ CORS)
admin.tsx /admin — login, forms, submissions, CSV export
prisma/
contract.prisma Data contract: Form, Submission
contract.json Generated — do not edit
contract.d.ts Generated — do not edit
db.ts Client wiring (Composer binding, or DATABASE_URL)
forms.ts All database access
seed.ts Demo form + submissions, only on an empty database
```

After editing `src/prisma/contract.prisma`:

```bash
bun run contract:emit
```

Never hand-edit `contract.json` or `contract.d.ts`.

## Limitations

This is deliberately small. Known gaps, all of them good first issues:

- **No rate limiting.** A public endpoint with no throttle is spammable beyond
what the honeypot catches. Per-IP or per-form limits would be the natural
next step (without storing the IP).
- **No email or webhook notifications.** Submissions only appear in the
dashboard; nothing pings you.
- **No file uploads.** Multipart requests carrying a file part are rejected
with a `415`.
- **Single admin password.** No user accounts, no roles, no 2FA. The session
cookie is `SameSite=Lax`, which blocks cross-site form posts, but there is no
per-request CSRF token.
- **No search or filtering** over submissions — just newest-first pagination
and the CSV export.
- **No spam scoring** beyond the honeypot.
Loading
Loading