# submitstream

Form-submission backend for vibe-coded sites — a private, minimal Gravity Forms +
Formspree. Sites POST their forms here; SubmitStream stores each entry, emails the
configured recipients (via Resend), logs a per-submission debug timeline, and retries
failed sends. Clients log in to view their own entries.

## Project Info
- Type: custom (FastAPI, not the default nginx static server)
- Internal port: 8000 → host `172.17.0.1:3106`
- App domains: **https://submitstream.bowden.works** (canonical) + https://submitstream.riverway.ca
- Created: 2026-06-16

## Architecture
- **FastAPI + raw sqlite3** (mirrors `/srv/apps/tracking-setup` conventions: `db.py`
  helpers, bcrypt, itsdangerous signed sessions). SQLite lives in `./data/app.db` (volume).
- `./app` is bind-mounted into the container, so code edits apply on **restart**.
  **`.env` changes apply only on `srv-gw deploy`** (compose `up` recreates the container);
  `srv-gw restart` is `docker restart` and keeps the OLD environment. This bit us once.
- Build is from the `Dockerfile` (python:3.12-slim). Redeploy after dep changes:
  `srv-gw deploy --project submitstream --build`.

### Key files (`app/`)
- `db.py` — schema (clients, forms, submissions, notifications, events) + query helpers.
- `routes/public.py` — **public** `POST /f/{public_key}` (submit), `OPTIONS` (CORS),
  `/embed.js`, `/health`. The submit flow: origin check → rate limit → parse → honeypot/
  timing → store → enqueue+send notifications → respond (303 redirect or `{ok:true}`).
- `notify.py` — Resend send + retry processor (`process_notification`, exp. backoff).
- `security.py` — honeypot, timing, in-memory rate limit, origin/CORS helpers.
- `auth.py` / `routes/auth_routes.py` — email+password (bcrypt), signed-cookie sessions,
  setup/reset tokens, CSRF (double-submit cookie, set by middleware in `main.py`).
- `routes/dashboard.py` — entries list/detail, event timeline, mark read/spam, CSV export,
  form settings, integration snippet, manual notification **resend**.
- `routes/admin.py` — rian-only: create clients + forms, issue setup links.
- `static/embed.js` — drop-in script (binds `form[data-submitstream="KEY"]`).

## SECURITY — non-negotiable invariants
- **Notification recipients are server-side only** (`forms.notify_recipients`). The submit
  body NEVER controls who is emailed — otherwise it's an open relay. Submitter's email is
  used only as `Reply-To`. Do not change this.
- Binds `172.17.0.1:3106` only (never `0.0.0.0`). `.env` is `chmod 660` (rian + project group).
- Public endpoint defenses: honeypot (`_hp`), timing (`_ts`, embed only), per-IP/form rate
  limit, per-form origin allowlist (empty = "learn mode": accept + log the origin).

## Integrating a vibe-coded form
1. Create the form in the dashboard (admin → create form), set notification recipients.
2. Give the site one of:
   - **Script:** add `data-submitstream="KEY"` to the `<form>` + `<script src="https://submitstream.bowden.works/embed.js" defer></script>`.
   - **Plain form:** `<form action="https://submitstream.bowden.works/f/KEY" method="post">` + a hidden honeypot input named `_hp` + set the form's success Redirect URL in Settings.
3. Live example: **wineestablishment.demoing.info** → form key `5YcRfgeoLc4` (wired via embed.js;
   old `mailto:` handler removed from its `main.js`).

### Thank-you page (set in the form's own HTML — no Settings needed)
- **Script (embed.js):** put it on the `<form>` — `data-redirect="/thank-you"` to send the
  visitor to a page, or `data-success="…"` for an inline message (default). It's the page's
  own JS navigating, so relative or absolute both work.
- **Plain no-JS form:** add `<input type="hidden" name="_redirect" value="https://yoursite.com/thank-you">`.
  Guarded by `security.safe_redirect` — only honored if it points back to the same site that
  submitted (same host as Origin) or a host in the form's allowed_origins; otherwise it falls
  back to the Settings redirect, then `/thanks`. This prevents the endpoint being an open redirect.
- Settings still has a `redirect_url` as the per-form fallback when the page sets nothing.

### Allowed-origins & moving a site to its live domain
- `allowed_origins` (per form, in Settings) controls which **website domains** may submit.
  A **bare root domain** (`client.com`) covers the apex, `www`, and all subdomains — add the
  root only, no need to list www/non-www. `*.domain` behaves the same. `https://exact.host`
  pins one exact origin. (See `security._pattern_matches`.) New forms default to `*.demoing.info`
  (`DEFAULT_ALLOWED_ORIGINS`) so they work on any gated test site but are never fully open.
  Empty = "learn mode" (accept anything, log the origin) — fallback, rarely needed now.
- **The form key and the page's `<script>`/action never change when a site moves domains.**
  The form always posts to `submitstream.bowden.works`; the client's domain needs no DNS for
  submissions to work. **Go-live = add the client's live domain to that form's allowed-origins**
  (e.g. `*.demoing.info, https://www.client.com, https://client.com`). That's the only step.
- Origin checks stop *other websites* piggybacking on your endpoint (browsers send a real
  Origin). They don't stop a scripted forged-header request — honeypot + rate limit cover that.

## Roles, access, and the management CLI (v0.4.0)
- **Roles:** `admin` (rian — everything), `staff` (devs like Adi — can create forms/clients
  via the API/CLI; see only forms they create or are granted), `client` (sees only granted
  forms' entries; cannot create or edit form config).
- **Form access is many-to-many** (`form_access` table). A form can be shared with several
  client logins; a client can be granted several forms. Grant/revoke from a form's **Settings**
  page ("Who can view these entries"), from an invite, or via the CLI/API.
- **Invites:** Admin page → "Create user" (role staff/client) emails a setup link; or the
  per-form grant box; or `submitstream grant`. All issue a setup token + email (Resend).
- **View-as:** uses the standard BW drop-in (`app/bw_view_as.py` + `app/viewas.py` policy).
  Dashboard shows a "View as" picker for admin/manager. **admin** → any non-admin; **manager**
  → clients sharing a form they created/were granted. **Read-only** (a write-guard middleware in
  `main.py` 403s any non-GET while impersonating, except `/view-as/*`, `/logout`, `/f/*`, `/api/*`).
  No-privilege-escalation via `viewas.rank_of`. Reports to the BW hub Live page via
  `bw_auth.report_impersonation` on start/stop. Persistence is still the signed `ss_viewas` cookie
  (target account id); `deps.dashboard_identity` re-authorizes it every request. Improve the
  mechanism in `/srv/system/id-auth/app-auth/bw_view_as.py` and re-copy.

### Headless management API + `bin/submitstream` CLI
- API lives at **`/api/*`** (`routes/api.py`), auth = per-user **API token** (`Authorization:
  Bearer sk_…`). Tokens are generated per user at **/account** (stored as SHA-256 hash; shown
  once). Endpoints: `whoami`, `GET/POST /api/forms`, `POST /api/clients`,
  `POST /api/forms/{key}/grant|revoke`. Shared create/grant/invite logic is in `services.py`
  (UI and API use the same code).
- **SECURITY: the management API is on-server only.** `deps.require_api_user` rejects any
  request carrying `X-Forwarded-*` (i.e. anything proxied through Caddy / the public URL).
  Only direct calls to the container bridge (`http://172.17.0.1:3106`) are accepted — verified:
  the public `submitstream.bowden.works/api/*` returns 403 even with a valid token.
- **CLI** (`bin/submitstream`, stdlib-only Python): reads the token from
  `$SUBMITSTREAM_TOKEN` or `~/.config/submitstream/token` (chmod 600), hits the bridge.
  Commands: `whoami`, `list-forms`, `get-form`, `create-form`, `update-form`, `set-origins`,
  `add-origin`, `create-client`, `grant`, `revoke`, `delete-form`, `delete-client`.
  **Go-live for a site = `submitstream set-origins --form KEY --origins "*.demoing.info,
  clientroot.com"`** (root domain covers www + apex + subdomains). `add-origin --form KEY
  --domain x.com` appends one. `update-form` also sets name/notify/redirect/active.
  (UI also has delete: form Settings "Danger zone", entry
  detail, admin users table. FK cascade removes a form's entries/notifications/events/grants;
  deleting a user keeps forms they created. Admins can't be deleted; you can't delete yourself.)
  To let another dev (Adi) use it: rian copies `bin/submitstream` to `/usr/local/bin/` (sudo,
  since `/srv/apps/submitstream` is group-restricted), Adi logs in → Account → generates his
  own token → saves to `~/.config/submitstream/token`. Actions are then attributable to Adi
  and individually revocable.
- **For Claude (creating forms on demand):** run `submitstream create-form --name … --notify …
  [--grant client@x.com]`, then wire the returned `script_snippet`/`submit_url` into the site.
  Use `create-client`/`grant` to give a client dashboard access. Staff tokens can only act on
  their own forms; only admin tokens can create staff/admin users.

## Email (Resend)
- Sending domain: `submitstream.bowden.works` — must be **verified in Resend** (one-time SPF/
  DKIM/DMARC DNS). Optional per-form `from_address` (admin only) for a client's own verified domain.
- Until `RESEND_API_KEY` is real, the app runs in **DEV_EMAIL_PRINT** mode: emails are printed
  to `srv-gw logs --project submitstream` instead of sent (also auto-on if the key is the
  placeholder). After adding the key, set `DEV_EMAIL_PRINT=0` and **`srv-gw deploy`** (not restart).

## Managing
```bash
srv-gw deploy  --project submitstream            # apply .env / compose changes
srv-gw deploy  --project submitstream --build    # rebuild image (after requirements.txt change)
srv-gw restart --project submitstream            # apply app/ code changes only
srv-gw logs    --project submitstream
```
