# Spread Upload a bill of materials, re-quote every line against a supplier catalog, see market price / Spread price / savings line by line. We keep 20% of what you save, or nothing. The frontend is the landing page (`index.html` + `app.js`). The pricing engine is a small Go package (`internal/spread`) with **no external dependencies** — CSV parsing is stdlib, and the `.xlsx` reader is a hand-rolled unzip + XML pass. One core package, two entrypoints: - `api/analyze.go` — Vercel Go serverless function (`POST /api/analyze`) - `main.go` — self-hostable single binary that embeds the static files and serves the same handler ## Develop (live reload) The flake dev shell provides Go, `air` (live reload), and a local Postgres: ```sh nix develop # drops you in the dev shell with go + air + pg tools air # builds + runs, rebuilds on any .go/.html/.js/.sql change ``` Open http://localhost:8137. `air` watches everything including the embedded assets, so edits to `index.html` / `app.js` reload too. Demo mode needs no database — accounts stay dark until you start Postgres. For the account routes (`/login`, `/admin`, `/app`), from inside `nix develop`: ```sh pg-start # spins up a throwaway Postgres under ./.pgdata spread-admin you@example.com secret # create/reset the admin user air # /admin and /app are now live pg-stop # when you're done ``` `SPREAD_DB_OPTIONAL=1` is set in the dev shell, so `air` still serves the public demo even if you haven't run `pg-start`. ### Admin CMS Log in as the admin (internal subscriber) to reach `/admin`: - **`/admin/catalog`** — the live supplier catalog (part number, description, spread price). "Seed demo catalog" loads the five landing-page parts. This catalog drives authed re-quotes; the public demo keeps its built-in prices. - **`/admin/subscribers`** — create customer subscribers and their users. Leave the password blank to email them a one-time invite link to set their own; fill it in to set a password directly. - **`/admin/boms`** — review every subscriber's uploaded BOMs; open one to see the full line-by-line re-quote. Customers (any non-internal subscriber) land on `/app`, where they upload a BOM, have it re-quoted against the live catalog, and see it saved to their account. `/app/bom/{id}` is the saved report; a customer can only open their own, the internal subscriber can open any. `/admin` redirects customers back to `/app`. ## Accounts: invites, resets, email New users get a one-time invite link (or you set a password directly). Both invites and `/forgot` password resets use short-lived, single-use tokens. Email goes through a small `Mailer` interface picked by `MAIL_BACKEND`: - `log` (default) — writes the message + link to the server log; the whole invite/reset flow works locally with no email setup (copy the link from the log). - `smtp` — stdlib `net/smtp`; set `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `MAIL_FROM`. - `sendgrid` — SendGrid v3 API; set `SENDGRID_API_KEY` / `MAIL_FROM`. `SPREAD_BASE_URL` sets the host used in emailed links (default `http://localhost:8137`). ## Security notes - **CSRF**: all state-changing forms carry a token validated against a double-submit cookie (`SameSite=Lax`, httponly). The public `/api/analyze` demo endpoint is intentionally open (no auth, no CSRF). - Sessions and one-time tokens are stored hashed; passwords are bcrypt. - Set `SPREAD_SECURE_COOKIES=1` behind HTTPS so cookies are marked `Secure`. ## Run without the flake ```sh nix shell nixpkgs#go --command go run . # or just `go run .` with Go installed ``` Then open http://localhost:8137. Override the port with `PORT=9000 go run .`. The example section loads `example-bom.csv` on first paint, which reproduces the canonical demo figures: **$883.50 total savings · $176.70 our cut · $706.80 net**. ## The demo BOM `example-bom.csv` uses a standard BOM layout. The parser is tolerant of column order and header naming (case-insensitive, punctuation-stripped): | Canonical field | Accepted headers | |-----------------|------------------| | Part number | part number, part, PN, MPN, mfr part number | | Description | description, desc, name, component | | Manufacturer | manufacturer, mfr, mfg, brand | | Quantity | quantity, qty, count | | Unit price | unit price, price, cost, unit cost, current price | Parts found by exact part number get a fixed catalog price. Everything else is estimated from a category model (a per-category discount plus a small volume bonus) and flagged `· est.` in the UI. Replace `catalog.go` with real supplier lookups to go live — nothing else changes. Swap in your real BOM later by uploading it, or replace `example-bom.csv`. ## Deploy to Vercel The repo is a zero-config Vercel project: - static files (`index.html`, `app.js`, `example-bom.csv`) served from the CDN - `api/analyze.go` auto-detected as a Go function via `go.mod` - `main.go` is excluded from the deploy (see `.vercelignore`) ```sh vercel # preview vercel --prod # production ``` ## Self-host on your own box Build one static binary and run it behind nginx (no runtime deps): ```sh go build -o spread . PORT=8080 ./spread ``` ### NixOS (full app, TLS, Postgres) The flake ships `packages.default` (the `buildGoModule` binary) and `nixosModules.default` (systemd service + Postgres + nginx + Let's Encrypt). In your host flake: ```nix { inputs.spread.url = "path:/home/tony/spread"; # or a git URL # ... outputs = { self, nixpkgs, spread, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { modules = [ spread.nixosModules.default { services.spread = { enable = true; domain = "spread.example.com"; # the subdomain you point here acmeEmail = "you@example.com"; }; } ]; }; }; } ``` `nixos-rebuild switch`, then bootstrap the first admin once: ```sh sudo -u spread \ DATABASE_URL='postgresql:///spread?host=/run/postgresql' \ SPREAD_ADMIN_PASSWORD='...' \ $(readlink -f /run/current-system)/sw/bin/spread init-admin you@example.com # (or: nix run .#default -- init-admin ... with the same env) ``` **Networking (home box behind NAT):** point an A record for the subdomain at your public IP, and forward TCP **80 + 443** on your router to this machine. Let's Encrypt needs 80/443 reachable; the app itself stays on `127.0.0.1:8137` behind nginx. If your public IP is dynamic, either use DDNS or just update the A record before a demo. For real email set `services.spread.mailBackend = "smtp"` (or `"sendgrid"`) and point `environmentFile` at a secrets file (`SMTP_*` / `SENDGRID_API_KEY` / `MAIL_FROM`) kept out of the Nix store.