spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git

Files

040000 api/
040000 assets/
040000 internal/
100644 .air.toml
100644 .gitignore
100644 .vercelignore
100644 app.js
100644 example-bom.csv
100644 flake.lock
100644 flake.nix
100644 go.mod
100644 go.sum
100644 index.html
100644 main.go
100644 README.md
100644 vercel.json

Recent commits

c9ca099 added login link to landing nav tonybanters 2026-07-05
37e229a added spread bom re-quoting webapp tonybanters 2026-07-05

View all commits

README.md

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:

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:

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

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)
vercel        # preview
vercel --prod # production

Self-host on your own box

Build one static binary and run it behind nginx (no runtime deps):

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:

{
  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:

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.