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 |
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— stdlibnet/smtp; setSMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/MAIL_FROM.sendgrid— SendGrid v3 API; setSENDGRID_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/analyzedemo endpoint is intentionally open (no auth, no CSRF). - Sessions and one-time tokens are stored hashed; passwords are bcrypt.
- Set
SPREAD_SECURE_COOKIES=1behind HTTPS so cookies are markedSecure.
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.goauto-detected as a Go function viago.modmain.gois 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.