Map how a process actually works — the steps, who does them, how long they take, and the loops where it doubles back on itself.
Most diagramming tools give you boxes and arrows and stop there. flowmap is built to be a living process document: every step carries a status, an owner, a duration, and notes, so the map stays useful after the pretty picture is drawn. It holds any number of maps at once, like a small Drive for process diagrams — with an account of your own, so your maps follow you across browsers and devices.
You need a Postgres database (a free Neon project works) —
put its connection string in .env.local:
DATABASE_URL=postgresql://...
Create the tables once:
psql "$DATABASE_URL" -f db/schema.sqlThen:
npm install && npm run devThis runs the Vite dev server and a local API dev server together (concurrently),
with /api proxied from Vite to the API server. Both talk to the same handler
code that runs on the production deployment.
The /api handlers are Vercel functions, so the whole thing — frontend
and backend — deploys as one Vercel project. It cannot be hosted on a static
host: without a server for /api, sign-up and login have nothing to talk to.
npx vercel link
npx vercel env add DATABASE_URL production
npx vercel --prod| Variable | Required | What it does |
|---|---|---|
DATABASE_URL |
yes | Neon connection string. |
RESEND_API_KEY |
no | Enables outbound email. Without it, password reset and verification links are generated but never delivered — the endpoints still behave identically so they give nothing away. |
AUTH_EMAIL_FROM |
no | Sender address, e.g. flowmap <hello@yourdomain>. Defaults to Resend's shared onboarding@resend.dev, which only delivers to the address that owns the Resend account — enough to test, not enough to mail anyone else. Sending to other people needs a verified domain. |
APP_URL |
no | Origin used to build emailed links. Read from configuration rather than the request's Host header, which is attacker-controlled and would otherwise let someone put their own domain into a real reset email. |
AUTH_REQUIRE_EMAIL_VERIFICATION |
no | true blocks sign-in until the address is confirmed. Leave it off until email is confirmed working, or every account locks out. |
ALERT_EMAIL |
no | Address to notify when a handler throws. Capped at 4 emails/hour. |
Neon's free tier keeps 6 hours of point-in-time history, so anything noticed
the next morning is already gone. .github/workflows/backup.yml runs nightly,
dumps the durable tables, encrypts the dump, and keeps it as an artifact for 90
days. It needs two repository secrets: DATABASE_URL and BACKUP_PASSPHRASE.
The encryption is not optional. This repository is public, and artifacts on a public repository can be downloaded by anyone who can see the Actions tab, while the dump contains every user's password hash. The workflow fails rather than upload a plaintext dump if the passphrase is missing.
To restore:
gpg --decrypt backup-YYYY-MM-DD.json.gpg > backup.json
DATABASE_URL=... node scripts/restore.mjs backup.jsonrestore.mjs refuses to run against a database that already holds users unless
given --force, since the expensive mistake is pointing a restore at the live
database rather than at an empty one. Rows are upserted by primary key, so a
--force run repairs missing rows instead of duplicating them.
Backups are only real once a restore has been rehearsed. This one was: dumped from production, restored into a throwaway Neon branch that had been emptied first, and checked row for row.
- Accounts — sign up with an email and password. Your maps are stored under your account, not just in one browser, so they're there whichever device you sign in from.
- Dashboard — the landing page. Search filters by name — handy once you
have more than a screenful of maps. + New map opens a blank one; Import
takes any number of files at once — single
.flowmap.jsonmaps, wholeflowmap-library.jsonbundles, or a mix — and you can drag them onto the dashboard instead, including a folder, which is walked recursively. Files that aren't flowmap exports are listed by name and skipped rather than failing the batch. Export all downloads every map on the dashboard as one bundle. Each card can be pinned, renamed (double-click the title, or the row action), duplicated, exported, or deleted, without opening it. - Steps — double-click empty canvas, or hit + Step. Select a step to edit its title, status, owner, duration, and notes in the side panel. Owner and duration surface on the node; status colors its left edge.
- Flow — drag from a step's right dot to the next step's left dot.
- Loops — drag from a step's top-right dot back to an earlier step's top-left dot. It renders as a dashed arc over the row, labeled with the repeat condition ("if changes requested").
- Rollup — the header keeps a running count of steps, loops, and total time.
- Sharing — Share on a map you own hands out access two ways. A link works for anyone you send it to, with no account and no sign-in; a person is named by email, needs a flowmap account, and can be removed individually. Both come as can view or can edit, and both are revocable at any time. Maps shared with you appear under Shared with me, and a view-only map opens with its editing controls gone.
Loops are never stored. Nothing on an edge says "I am a loop" — an edge is a loop because of where it sits in the graph, so re-route the map and the same edge stops being one on its own.
Deciding which edge in a cycle to call the loop is the part that isn't obvious. The textbook answer is a DFS back-edge search, and it's wrong here: which edge a DFS blames depends on where the traversal happens to start, so in a Draft→Review→Draft cycle it will happily label the forward edge as the loop.
findBackEdges instead builds the forward graph greedily,
considering edges most-rightward first — which is the direction these maps are
read — and whatever would close a cycle is a loop. Edges drawn between the
dedicated loop handles are considered last, so an arrow you explicitly drew as a
return arrow stays one.
A Vite + React frontend and a small set of Vercel functions under /api
sharing one Postgres database (Neon). Auth is plain email/password — passwords
hashed with scrypt, sessions as opaque tokens in a sessions table and an
httpOnly cookie — no third-party auth provider. Every map row belongs to one
user; pinned lives on the map itself rather than as a separate list.
Routing between the dashboard and an open map is a #/map/<id> hash, not a real
path — no server-side rewrite needed for it. The editor autosaves into the map's
row on a debounce, flushed synchronously (via fetch(..., { keepalive: true }))
on navigating away or closing the tab.
Saves carry the revision they were based on, and the update is conditional on
it. A write built on a stale revision is refused with 409 and the current
document, rather than applied — without that, autosave sending the whole
document every 400 ms means the last writer silently erases the other, which
happened with two browser tabs long before sharing existed. The editor stops
autosaving on a conflict and asks which version to keep; there is no automatic
merge, because interleaving two people's edits to a graph produces a document
neither of them wrote.
Export / Import move a single map as a .flowmap.json file; Export
all / Import move the whole dashboard as one flowmap-library.json
bundle. Either way, import always assigns fresh ids (carrying pinned status
along for a bundle), so re-importing a file — or importing someone else's
export — never collides with what's already on your dashboard. The saved shape
is a plain serializable MapDoc (src/types.ts).
Passwords must be at least 10 characters and are checked against Have I Been Pwned by SHA-1 range query — five hex characters go out, the comparison happens locally, the password itself never leaves the server. That check fails open, so a length floor and a common -password list still apply if the service is unreachable.
Sign-in is throttled per email (10 per 15 minutes) and per IP (30 per 15
minutes); sign-up is throttled per IP (5 per hour). Counters live in
rate_limits as fixed windows, because functions share no memory between
invocations. A successful sign-in clears that email's counter. Login runs the
password hash even for addresses with no account, so response time doesn't
reveal who is registered.
Everything a client writes to a map goes through
sanitizeMapInput, which rebuilds the document field by
field rather than validating in place — unknown properties are dropped instead
of stored, strings and counts are bounded, and edges pointing at steps that
don't exist are rejected. Responses carry a strict CSP, frame-ancestors 'none',
nosniff, and a referrer policy; /api responses are no-store.
Password reset and email verification run on single-use tokens: 32 random bytes,
stored only as a SHA-256 hash, so a database leak yields no usable link. The raw
token travels in the URL fragment, which browsers never send to a server,
keeping it out of access logs and Referer headers. Resetting a password
deletes every session for that account — whoever prompted the reset may be
holding a stolen one, so it has to end them, not just add a new way in.
Sharing resolves through one function, resolveAccess,
rather than a where user_id = … repeated per handler — so there is a single
place to audit. It checks ownership, then a named grant, then a link token, in
that order: someone who owns a map they were also sent a view link for still
edits it, rather than being demoted by the weaker credential they happen to
carry. Link tokens are 32 random bytes stored only as a SHA-256, scoped to one
map, and revocable; the raw token is shown once at creation and never again.
The share URL puts it in the fragment, which browsers never transmit, keeping it
out of server logs and Referer headers.
A map is fetched from a link with no session at all, so GET /api/maps/:id and
/api/shared are the only endpoints an unauthenticated stranger reaches — both
are rate-limited per IP, and both answer 404 rather than 403 for maps you
cannot see, since confirming a map exists is itself a disclosure.
Disabled controls are a courtesy, not the control: a viewer who opens devtools
can still call PUT, and the server refuses it. Editors deliberately cannot
delete or re-share, so granting edit access can never cost you the map.
Known gaps, deliberately: sharing by email discloses whether that address has an account — removing the tell needs pending invites delivered by email, which needs a verified sending domain. And this is not a real-time collaborative editor: concurrent edits are detected, not merged. Two people on one map is safe but not smooth, and making it smooth means CRDTs plus a persistent socket server, which Vercel functions cannot host.
React Flow for the canvas, Zustand for state, Vite + React + TypeScript on the frontend; Neon Postgres and Vercel functions on the backend. MIT licensed.
