Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions DEPLOY-subscribe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Deploying the subscribe Worker (email capture)

The homepage "Stay in the loop" form posts visitor emails to a small
standalone **Cloudflare Worker** backed by **D1**. The site itself stays
on **GitHub Pages** — there's no DNS migration. Only the email-capture
backend lives on Cloudflare.

How it fits together:

- The home form (`index.njk`) does a native `<form method="POST">` to the
Worker's URL (configured in `_data/site.json` → `subscribeEndpoint`).
- The Worker (`worker/subscribe.ts`) validates + normalizes the email,
inserts a row into the D1 `interests` table, then **303-redirects** the
browser to `https://parachute.computer/subscribe/thanks/` on success, or
back to `https://parachute.computer/?subscribe_error=1` on failure.
- Because it's a native form POST and the browser follows the redirect
itself (the page never reads the cross-origin response with JS), **no
CORS** is involved.

The interim D1 store is intentionally dumb — no de-dup, no confirmation
email, no admin UI. The data will later be synced into a Parachute vault.

---

## What's left for Aaron (Cloudflare)

One-time setup. Run these from this repo root.

1. **Log in to Cloudflare**

```bash
npx wrangler login
```

2. **Create the D1 database**

```bash
npx wrangler d1 create parachute-interests
```

This prints a `database_id`. **Paste it into `wrangler.toml`** in place
of the `database_id = "PLACEHOLDER"` line, then **commit that change**
(the placeholder must not stay in `main`).

3. **Apply the migration to the remote DB** (creates the `interests` table)

```bash
npx wrangler d1 migrations apply parachute-interests --remote
```

(Or `npm run db:migrate`.) For a local dev DB, use `--local` instead.

4. **Deploy the Worker**

```bash
npx wrangler deploy
```

This deploys `worker/subscribe.ts` as the `parachute-subscribe` Worker.
The deploy output includes a `*.workers.dev` URL.

5. **Point the form at the Worker.** Two options:

- **Custom domain (preferred):** in the Cloudflare dashboard →
Workers & Pages → `parachute-subscribe` → Settings → Domains &
Routes, add the custom domain `subscribe.parachute.computer`. (This
requires `parachute.computer` to be on a Cloudflare zone for DNS; if
it isn't, use the workers.dev fallback below.) `_data/site.json`
already defaults to `https://subscribe.parachute.computer/`, so no
code change is needed if you use this domain.
- **workers.dev fallback:** if you don't want a custom domain, copy the
`*.workers.dev` URL from step 4 into `_data/site.json` →
`subscribeEndpoint`, and commit. The form will post there instead.

6. **Smoke-test.** Load `https://parachute.computer/`, scroll to the
closing "Stay in the loop" block, submit a real email. You should land
on `/subscribe/thanks/`. Confirm the row landed:

```bash
npx wrangler d1 execute parachute-interests --remote \
--command "SELECT id, email, source_path, created_at FROM interests ORDER BY id DESC LIMIT 5"
```

To test the error path, submit an obviously bad email (e.g. `nope`) —
you should be redirected back to the home with a discreet inline error.

---

## Notes / known gaps

- **No rate limiting (yet).** The Worker accepts any POST; a bot could
spam rows. This is an accepted simple-start gap — fine for early
interest-list volume. When it matters, add a Cloudflare Turnstile check
or a WAF rate-limit rule on the Worker route (no code redeploy needed
for the WAF rule).
- **No secrets.** The Worker holds nothing sensitive beyond the D1
binding (`DB`), which is scoped to this Worker by Cloudflare. The D1
insert is parameterized (no SQL injection surface).
- **Email validation** is permissive on purpose — catches obvious typos
(`EMAIL_RE`, 254-char cap), not RFC 5322 compliance. D1 is the source of
truth; junk gets filtered downstream at vault-sync time.
- **No de-dup.** Duplicate signups are tolerated and preserve signal
(when someone came back, from where, via `source_path`).
- **`source_path` is often NULL.** It's derived from the `Referer`
header, but most browsers' default Referrer-Policy
(`strict-origin-when-cross-origin`) sends only the origin — no path —
on a cross-origin POST. So expect `source_path` to be NULL for the
majority of real submissions. Not a bug; the Worker handles it
gracefully. (If per-page attribution ever matters, add a hidden
`source` input to the form instead of relying on Referer.)
3 changes: 3 additions & 0 deletions _data/site.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"subscribeEndpoint": "https://parachute-subscribe.unforced.workers.dev/"
}
7 changes: 7 additions & 0 deletions eleventy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ module.exports = function (eleventyConfig) {

// Ignore non-content files
eleventyConfig.ignores.add("CLAUDE.md");
eleventyConfig.ignores.add("DEPLOY-subscribe.md");
eleventyConfig.ignores.add("blog/drafts/**");
eleventyConfig.ignores.add("node_modules/**");
eleventyConfig.ignores.add("archive/**");
// Subscribe Worker backend assets — not part of the static site output.
// The Worker deploys via `wrangler deploy` (a separate Cloudflare
// origin); `migrations/` + `wrangler.toml` are its config, not content.
eleventyConfig.ignores.add("worker/**");
eleventyConfig.ignores.add("migrations/**");
eleventyConfig.ignores.add("wrangler.toml");

// Date formatting filter (uses UTC to avoid timezone offset issues)
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
Expand Down
114 changes: 114 additions & 0 deletions index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,82 @@ permalink: /
.home-screen { padding: 3.5rem 0; }
.home-steps li { grid-template-columns: 1.75rem 1fr; gap: 0.9rem; }
}

/* Closing — quiet "stay in the loop" interest list, below the final CTA */
.home-subscribe {
max-width: 30rem;
margin: 2.5rem auto 0;
padding-top: 2rem;
border-top: 1px solid var(--border);
text-align: left;
}
.home-subscribe-eyebrow {
font-size: 0.72rem;
color: var(--fg-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 500;
margin-bottom: 0.6rem;
text-align: center;
}
.home-subscribe-lead {
font-family: var(--serif);
font-size: 1.15rem;
line-height: 1.35;
color: var(--fg-muted);
margin-bottom: 1rem;
text-align: center;
}
.home-subscribe-form {
display: flex;
gap: 0.5rem;
align-items: stretch;
flex-wrap: wrap;
margin: 0;
}
.home-subscribe-input {
flex: 1;
min-width: 200px;
padding: 0.72rem 0.95rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
color: var(--fg);
background: #fff;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.home-subscribe-input::placeholder { color: var(--fg-dim); }
.home-subscribe-input:focus {
outline: none;
border-color: var(--accent-light);
box-shadow: 0 0 0 3px rgba(74, 124, 89, 0.12);
}
.home-subscribe-form .home-cta {
border: none;
cursor: pointer;
font-family: inherit;
margin: 0;
padding: 0.72rem 1.4rem;
font-size: 0.9rem;
white-space: nowrap;
}
.home-subscribe-error {
margin-top: 0.75rem;
font-size: 0.85rem;
color: #b04848;
line-height: 1.5;
text-align: center;
}
.home-subscribe-note {
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--fg-dim);
line-height: 1.5;
text-align: center;
}
.home-subscribe-note a { color: var(--fg-muted); text-decoration: underline; text-underline-offset: 2px; }
.home-subscribe-note a:hover { color: var(--accent); }
</style>

<main class="home">
Expand Down Expand Up @@ -315,6 +391,44 @@ permalink: /
<h2>Start thinking together.</h2>
<a href="/start/" class="home-cta">Get started &rarr;</a>
<p class="home-close-note">~2 minutes, one command</p>

{# Quiet interest list. A NATIVE cross-origin form POST to the
subscribe Worker (a different origin) — needs no CORS because
the browser follows the Worker's 303 redirect itself; the page
never reads the response with JS. The Worker redirects to
/subscribe/thanks/ on success, or back here with
?subscribe_error=1 on failure (surfaced by the script below). #}
<div class="home-subscribe">
<p class="home-subscribe-eyebrow">Stay in the loop</p>
<p class="home-subscribe-lead">Updates as Parachute opens up &mdash; thoughtful and infrequent.</p>
<form class="home-subscribe-form" method="post" action="{{ site.subscribeEndpoint }}">
<input
type="email"
name="email"
required
autocomplete="email"
placeholder="you@example.com"
aria-label="Email address"
class="home-subscribe-input"
/>
<button type="submit" class="home-cta">Subscribe</button>
</form>
<p class="home-subscribe-error" id="home-subscribe-error" hidden>That didn't go through &mdash; check the email and try again.</p>
<p class="home-subscribe-note">No spam, unsubscribe any time. Or just <a href="mailto:hello@parachute.computer">say hello</a>.</p>
</div>
<script>
// Surface a subtle error if the Worker bounced us back with
// ?subscribe_error=1. No JS dependency for the happy path —
// the form is a plain browser POST → 303 redirect.
(function () {
var params = new URLSearchParams(window.location.search);
if (params.get('subscribe_error') === '1') {
var el = document.getElementById('home-subscribe-error');
if (el) el.hidden = false;
}
})();
</script>

<nav class="home-thin-footer">
<a href="/start/">Start</a>
<a href="/blog/">Blog</a>
Expand Down
26 changes: 26 additions & 0 deletions migrations/0001_interests.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Migration 0001 — interests table
--
-- V1 of the Parachute interest list (issue #25). Captures email signups
-- from the homepage form via the standalone subscribe Worker.
--
-- Schema mirrors LVB's interests table after #44, minus the JSON tags
-- column (Parachute V1 doesn't segment). Two reserved columns kept:
-- user_id — NULL for V1; reserved for future identity linking
-- once Parachute has user accounts.
-- resend_contact_id — NULL for V1; reserved for V2 Resend audience sync.
--
-- No UNIQUE constraint on email — duplicate signups are tolerated and
-- preserve signal (when someone came back, from where).

CREATE TABLE interests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT, -- reserved; the V1 form collects email only (NULL for now)
source_path TEXT, -- pathname from Referer; often NULL on cross-origin POST
-- (default Referrer-Policy sends origin only, no path)
user_id INTEGER,
resend_contact_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_interests_email ON interests(email);
Loading