← back to Petitionyour
README.md
227 lines
# petitionyour.org — Petition Your Representatives
A real, working civic petition MVP: browse petitions, start one, sign one
(live signature count + share links), and look up your representatives by
ZIP — free path only, no paid APIs.
## Status
- **Local build: done, working, git-committed.**
- **Prod deploy: NOT fired.** DNS for `petitionyour.org` already points at
Kamatera (`45.61.58.125`), but there is no nginx vhost or SSL cert for it
yet — see "Deploy runbook" below, which is a Steve-run/approved paste, not
something this session executed.
- Heads-up: there is an **unrelated, pre-existing, broken** pm2 app also
named `petitionyour` on Kamatera serving **petitionyour.COM** (not .org) —
a crash-looping Next.js build (`/root/public-projects/petitionyour`,
30 restarts, "Failed to find Server Action" errors, nginx vhost dated
Aug 17). It is untouched by this build. This project intentionally uses
a different pm2 name (`petitionyour-org`) and a different remote directory
(`/root/public-projects/petitionyour-org`) so the two can never collide.
Worth a cleanup pass on the .com app separately, out of scope here.
## Stack
- Express 4 + EJS server-rendered views (no build step, no client framework)
- Durable storage: **plain JSON files** (`data/petitions.json`,
`data/signatures.json`) via `lib/store.js` — atomic writes (temp file +
rename), synchronous reads (Node is single-threaded, so no interleaved
writes to worry about at this scale). Chosen over sqlite for the deploy:
zero native deps, zero node-gyp/build-toolchain risk on Kamatera, fully
git-diffable, and trivially reversible (it's just JSON — restore a git
revision to roll back any bad write).
- Dark/light toggle from day one (Steve's standing rule) — anti-flash inline
script + CSS vars in `public/css/site.css`, pattern lifted from
`restaurant-directory`.
- Sort + density controls on the browse grid (Steve's standing rule) —
`public/js/site.js`, localStorage-persisted (`petitionyour:sort`,
`petitionyour:density`), plus search + category filter.
## Local preview
```sh
cd ~/Projects/petitionyour
npm install
npm run dev # PORT=4000 node server.js
open http://127.0.0.1:4000/
```
Or `npm start` for `PORT=4000` (or set your own `PORT=xxxx node server.js`).
Health check: `curl http://127.0.0.1:4000/health` → `ok`.
Seed data ships 3 example petitions with a few example signatures so the
grid isn't empty on first load — safe to leave, or delete
`data/petitions.json` / `data/signatures.json` (they degrade gracefully to
`[]` if missing).
## Data model
`data/petitions.json` — array of:
```
{ id, slug, title, description, target, targetType, category, creatorName, createdAt }
```
`data/signatures.json` — array of:
```
{ id, petitionId, name, email, zip, comment, showNamePublicly, updatesOptIn, createdAt }
```
Signature count is always computed live from `signatures.json` (never
stored/cached on the petition record), so it can't drift.
One signature per (petition, email) — duplicate signing attempts 400.
**Privacy / consent**: email is required (to prevent duplicate signing) but
never shown publicly. Name is shown publicly only if the signer checks
"show my name publicly" (default on, easy to uncheck for anonymous signing).
`updatesOptIn` is captured as a consent flag only — **no email-sending
mechanism is wired**, per the task's hard rule (no send-to-list without
Steve). If/when an update-email feature is built, it must read this flag
and include an unsubscribe link, per the same rule.
## Find-your-representatives (ZIP lookup) — design decision
Free path only, and **honest about coverage**:
- `data/zip3-state-ranges.json` is a bundled, offline ZIP3-prefix → state
range table (standard USPS allocation ranges, covers 50 states + DC +
PR/VI/Guam). `lib/reps.js` resolves a ZIP to a state deterministically,
$0, no network call.
- We deliberately do **not** bundle a static list of senator/representative
*names*. Officeholders change (elections, resignations, special
elections, appointments) and my knowledge cutoff is already months stale
— a hardcoded name list would be silent civic misinformation, which is
worse than no data on a "credible, trustworthy .org" site. Instead, once
we know the state, we hand back direct links to the **official, always-
current** .gov lookup tools:
- House.gov's own ZIP-based "Find Your Representative" tool
- Senate.gov's senator contact list
- Congress.gov member search
- USA.gov's elected-officials-by-address tool
- This is the "or link out" option the brief explicitly sanctioned as a
reasonable v1. `GET /find-reps` (page) and `GET /api/find-reps?zip=`
(JSON, used by the inline widget on every petition detail page) both use
it. Tested against 60614→IL, 90210→CA, 10001→NY — all correct.
## Routes
| Route | Method | What |
|---|---|---|
| `/` | GET | Browse petitions (sort + density + search + category filter) |
| `/petitions/new` | GET/POST | Create a petition |
| `/petitions/:slug` | GET | Detail page — description, sign form, share links, supporters, reps widget |
| `/petitions/:slug/sign` | POST | Sign (name + email required, ZIP optional) |
| `/find-reps` | GET | Standalone ZIP → state → official links page |
| `/api/find-reps?zip=` | GET | JSON version of the same lookup |
| `/health` | GET | 200 `ok` — deploy smoke-test target |
## Deploy runbook (GATED — Steve runs this, nothing here has been fired)
Nothing in this section has been executed. Mac2 is also classifier-blocked
from prod SSH writes, so this is the paste Steve (or an approved session)
runs by hand, in order:
**1. nginx vhost + reload (verify `nginx -t` clean before reload — one bad
vhost freezes reload for the WHOLE box, see `kamatera-nginx-canary`):**
```sh
! scp ~/Projects/petitionyour/deploy/nginx-petitionyour.org.conf \
root@45.61.58.125:/etc/nginx/sites-available/petitionyour.org
! ssh root@45.61.58.125 "ln -sf /etc/nginx/sites-available/petitionyour.org /etc/nginx/sites-enabled/petitionyour.org && nginx -t"
```
If `nginx -t` reports OK, reload:
```sh
! ssh root@45.61.58.125 "systemctl reload nginx"
```
If it fails, **do not reload** — fix the vhost first (the shipped template
has the HTTPS `listen [::]:443 ssl;` line commented out by default because
novasuede.com's live vhost hit a tailscale-holds-v6:443 conflict on this
exact box; only uncomment it after confirming there's no conflict).
Note: the vhost's HTTPS server block references a cert that doesn't exist
yet (`/etc/letsencrypt/live/petitionyour.org/...`) — nginx will fail to
start THAT block until step 2 issues the cert. Standard certbot chicken-
and-egg; the usual fix is requesting the cert with `--nginx` (which edits
the vhost itself) or temporarily commenting the `ssl_certificate` lines
until after step 2. Steve/whoever runs this should use whichever certbot
plugin is already standard for this box (check how novasuede.com's cert
was issued: `ssh root@45.61.58.125 "certbot certificates | grep -A3 novasuede"`).
**2. Let's Encrypt cert:**
```sh
! ssh root@45.61.58.125 "certbot --nginx -d petitionyour.org -d www.petitionyour.org"
```
**3. Deploy the app (rsync + npm install + pm2 + health check):**
```sh
! cd ~/Projects/petitionyour && ./deploy-kamatera.sh
```
Or dry-run first to see exactly what it would do:
```sh
! cd ~/Projects/petitionyour && ./deploy-kamatera.sh --dry-run
```
**4. Verify all four layers** (the script's `health_check` does this, or
run it standalone):
```sh
! cd ~/Projects/petitionyour && ./deploy-kamatera.sh --health-only
```
- origin direct (HTTP via Host header) → 200
- CF proxied (HTTPS) → 200
- SSL cert valid (openssl dates) → not expired, matches petitionyour.org
- www subdomain → 200
**5. Cloudflare status — checked (read-only, no changes made):**
`petitionyour.org` is still on **GoDaddy nameservers** (`ns03/ns04.
domaincontrol.com`), NOT Cloudflare's — its A record points straight at
`45.61.58.125` with no CF proxy in front. (For contrast, `petitionyour.COM`
— the unrelated pre-existing app — IS on Cloudflare nameservers.) This
matches the `losangelesfabrics.com` precedent noted in the onboard-domain-
agent playbook: NS still on DOMAINCONTROL.COM, Cloudflare onboarding
pending a Steve go-ahead.
Two honest options for Steve to choose between — **this session picked
neither; both are gated:**
- **A — ship now, direct-to-origin SSL.** Since DNS isn't behind Cloudflare,
`certbot --nginx` in step 2 will complete a normal HTTP-01 challenge with
no CF interference. Fastest path to live. The vhost's `set_real_ip_from`
Cloudflare blocks are harmless no-ops until/unless Cloudflare is added
later.
- **B — onboard to Cloudflare first** (CF proxy, WAF, analytics, matches
the rest of the fleet) via the `/domain-setup` skill's zone-create +
NS-swap flow, *then* deploy. This is the "irreversible-ish" NS-swap
moment the skill explicitly says to surface to Steve and only proceed on
his explicit OK — so it's a separate gated step, not bundled into this
runbook.
**6. GA4** (DW spec checklist — analytics before going live):
```sh
! python3 ~/.claude/skills/analytics/scripts/create_property.py --name "Petition Your Representatives" --domain "petitionyour.org"
! python3 ~/.claude/skills/analytics/scripts/inject_gtag.py --project ~/Projects/petitionyour --measurement-id G-XXXXXXXX
```
(Fill in the real measurement ID the first script returns.)
**7. Reconcile to CNCP** after it's live:
Edit `~/cncp-starter/cncp-config.json` → this domain's entry → `ip:
"45.61.58.125"`, `statusLabel: "Live"`, `dnsHost: "Cloudflare"` (if
Cloudflare-fronted) → restart CNCP.
## Known gaps / honest limitations (v1)
- ZIP→state coverage is approximate (standard USPS prefix ranges); a
handful of edge-case ZIPs may not resolve — the UI says so and links to
the official lookups directly.
- No House-district-level lookup (that needs precise geocoding, which isn't
free/simple) — we link to House.gov's own official ZIP-based finder
instead of building our own.
- No moderation/review queue on petition creation — publishes immediately,
as stated on the create form.
- No email verification loop on signing (email is validated for format +
uniqueness only, not confirmed via a sent link) — keeps v1 simple and
avoids any outbound email wiring, which is explicitly out of scope until
Steve approves a send flow with proper consent/unsubscribe.