Skip to content
Open
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
33 changes: 29 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,36 @@ Current-era architecture lives in `design/` as markdown design notes, indexed fr

## Deployment

GitHub Pages deployment needs to run `npx @11ty/eleventy` and serve `_site/`. Options:
- GitHub Action that builds on push to main, then deploys the `_site/` output
- Or configure GitHub Pages to use a GitHub Action workflow instead of serving directly from `website/`
**As of #25 (2026-05): migrated from GitHub Pages → Cloudflare Pages.** The Pages project builds with `npx @11ty/eleventy` (output `_site/`) and auto-deploys on push to main. The old GH Pages workflow is archived; the `CNAME` file is no longer load-bearing (Cloudflare manages the custom domain).

**CNAME**: `parachute.computer` (DNS needs to point to GitHub Pages)
**CNAME**: `parachute.computer` (DNS now points at Cloudflare Pages)

---

## Interest list / backend

The site has a small backend now (issue #25): an interest-list signup form on the homepage that writes to D1.

```
functions/api/subscribe.ts Pages Function — POST handler
validates email, inserts into D1,
redirects to /subscribe/thanks/
migrations/0001_interests.sql D1 schema for the `interests` table
wrangler.toml D1 binding (`DB`) + Pages config
subscribe/thanks.njk /subscribe/thanks/ success page
index.njk hosts the inline subscribe form
INFRASTRUCTURE.md one-time CF setup steps Aaron runs
```

**The schema** (`interests`): `id`, `email`, `name`, `source_path`, `user_id` (reserved), `resend_contact_id` (reserved), `created_at`. No UNIQUE on email — duplicate signups preserve signal.

**No de-dup, no Resend, no admin UI in V1.** Query D1 with `wrangler d1 execute parachute-db --remote --command "SELECT * FROM interests ORDER BY id DESC LIMIT 50"` when you want to see the list. V2 adds Resend; V3 links to a future Parachute user store.

**Adding new Pages Functions**: drop a TypeScript file under `functions/`. The path mirrors the URL — `functions/api/subscribe.ts` → `/api/subscribe`. Export `onRequestPost` / `onRequestGet` / etc. Cloudflare's Pages docs cover the conventions.

**Local dev with the function + D1**: `npx wrangler pages dev _site --d1 DB=parachute-db` (after `npm run build`). Plain `npx @11ty/eleventy --serve` works for static-only iteration.

**Migrations**: `wrangler d1 migrations apply parachute-db --local` (local) or `--remote` (prod). Always pause-and-confirm before running against prod.

---

Expand Down
101 changes: 101 additions & 0 deletions INFRASTRUCTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Infrastructure setup — parachute.computer

One-time Cloudflare setup for the V1 interest list (issue #25). Aaron runs these once after this PR is reviewed; tentacles do not.

This doc captures only the moving parts that aren't already in the repo. The code (Pages Function, migration SQL, wrangler config, form) all lives in this repo and deploys from main.

---

## Sequence

1. **Create the Cloudflare Pages project**
- Cloudflare dashboard → Workers & Pages → Create → Pages → Connect to Git → select `ParachuteComputer/parachute.computer`
- **Production branch:** `main`
- **Build command:** `npx @11ty/eleventy`
- **Build output directory:** `_site`
- **Root directory:** `/` (default)
- No environment variables needed at this stage
- Project name suggestion: `parachute-computer` (matches `wrangler.toml`)

2. **Create the D1 database**
```
npx wrangler d1 create parachute-db
```
Copy the `database_id` from the output and paste it into `wrangler.toml` (replace `PLACEHOLDER_FILL_AFTER_D1_CREATE`). Commit + push so the deploy picks it up.

You can also create the DB from the dashboard (Workers & Pages → D1 → Create database) if you prefer — same result.

3. **Apply the initial migration**
```
npx wrangler d1 migrations apply parachute-db --remote
```
This creates the `interests` table from `migrations/0001_interests.sql`. Confirm the prompt before it runs against prod.

Smoke-check:
```
npx wrangler d1 execute parachute-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'"
```
You should see `interests`.

4. **Bind D1 to the Pages project**
- Pages project → Settings → Functions → D1 database bindings → Add binding
- **Variable name:** `DB`
- **D1 database:** `parachute-db`
- Apply to **Production** (and Preview if you want preview deployments to write to D1 — generally fine since duplicate test rows are harmless).

The `[[d1_databases]]` block in `wrangler.toml` gives the same binding for `wrangler pages dev` locally; the dashboard binding is what production uses.

5. **DNS swap: GitHub Pages → Cloudflare Pages**
- Pages project → Custom domains → Set up a custom domain → `parachute.computer`
- Cloudflare will guide the DNS update. If `parachute.computer` is already on Cloudflare DNS, it's a one-click toggle. If it's elsewhere, point the apex to the Pages target Cloudflare gives.
- Once DNS resolves, the existing `CNAME` file becomes irrelevant (Pages routes via the custom domain config, not `CNAME`).

6. **Archive the GitHub Pages workflow**
- Either delete `.github/workflows/deploy.yml`, or disable the workflow from GitHub → Actions. CF Pages handles the build + deploy now.
- I (the tentacle) left it in place on the PR — flip it off after the CF deploy is healthy so we don't double-deploy.

---

## Smoke test (after step 5)

1. Visit `https://parachute.computer/`, submit your email on the subscribe form.
2. You should land on `/subscribe/thanks/`.
3. Confirm the row landed:
```
npx wrangler d1 execute parachute-db --remote --command \
"SELECT id, email, source_path, created_at FROM interests ORDER BY id DESC LIMIT 5"
```

If the form bounces back to `/?subscribe_error=1`, check the Pages Function logs in the dashboard (Pages project → Functions → Logs).

---

## What's still on you (not in V1)

- **Resend integration (V2)** — confirmation email when someone subscribes.
- **Admin UI** — for now, query D1 directly via `wrangler d1 execute`.
- **Identity linking** — wired into the schema (`user_id` column) but not used until Parachute has user accounts (V3).
- **Substack export** — your call whether to migrate existing Substack subscribers in. Schema doesn't care; you can `INSERT` them by hand or via a one-off script.

---

## Useful commands

```
# Local dev with Pages Functions + D1 (after `npm run build`)
npx wrangler pages dev _site --d1 DB=parachute-db

# Apply migrations locally
npx wrangler d1 migrations apply parachute-db --local

# Apply migrations to prod (pause-and-confirm)
npx wrangler d1 migrations apply parachute-db --remote

# Inspect interests in prod
npx wrangler d1 execute parachute-db --remote --command \
"SELECT * FROM interests ORDER BY id DESC LIMIT 50"

# Count signups by source page
npx wrangler d1 execute parachute-db --remote --command \
"SELECT source_path, COUNT(*) FROM interests GROUP BY source_path"
```
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("INFRASTRUCTURE.md");
eleventyConfig.ignores.add("blog/drafts/**");
eleventyConfig.ignores.add("node_modules/**");
eleventyConfig.ignores.add("archive/**");
// Cloudflare Pages backend assets — not part of the static site output.
// Pages picks up `functions/` directly; `migrations/` + `wrangler.toml`
// are config, not content.
eleventyConfig.ignores.add("functions/**");
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
103 changes: 103 additions & 0 deletions functions/api/subscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// /api/subscribe — Cloudflare Pages Function
//
// V1 of the Parachute interest list (issue #25). Accepts a POST from the
// homepage email form, validates + normalizes the email, inserts a row
// into the D1 `interests` table, and redirects to /subscribe/thanks/.
//
// Reference pattern: LVB's `/api/interests` route (Hono on Workers). This
// is the Pages Functions equivalent — no Hono, just the Pages Functions
// `onRequestPost` handler.
//
// V1 deliberately does not:
// - de-dupe on email (duplicate signups preserve signal)
// - send a confirmation email (no Resend yet — V2)
// - link to a user account (no user store yet — V3)
// Two reserved columns on the table (`user_id`, `resend_contact_id`) keep
// the door open for those without a future migration.
//
// CORS: not needed. Same-origin POST from the parachute.computer form to
// a Pages Function on the same Pages project.

interface Env {
DB: D1Database;
}

// Permissive but reasonable email check. Mirrors LVB: the goal is to
// catch obvious typos (missing @, runaway length), not to be RFC 5322
// compliant. D1 is the source of truth — anything that gets in here that
// turns out to be junk is filtered downstream.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_EMAIL_LEN = 254;

const REDIRECT_THANKS = "/subscribe/thanks/";
const REDIRECT_ERROR = "/?subscribe_error=1";

function redirect(location: string): Response {
// 303 See Other — correct for a POST → GET handoff. Browsers will GET
// the redirect target rather than re-POSTing.
return new Response(null, {
status: 303,
headers: { Location: location },
});
}

function sourcePathFromReferer(referer: string | null): string | null {
if (!referer) return null;
try {
return new URL(referer).pathname || null;
} catch {
return null;
}
}

async function readEmail(request: Request): Promise<string> {
// Accept both standard form-encoded posts (default browser form
// behavior, no JS required) and JSON in case anyone POSTs from a
// script. Keep this simple — one input field.
const contentType = request.headers.get("Content-Type") || "";
if (contentType.includes("application/json")) {
const body = (await request.json().catch(() => ({}))) as { email?: unknown };
return typeof body.email === "string" ? body.email : "";
}
const form = await request.formData();
const value = form.get("email");
return typeof value === "string" ? value : "";
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
let raw: string;
try {
raw = await readEmail(request);
} catch {
return redirect(REDIRECT_ERROR);
}

const email = raw.trim().toLowerCase();
if (!email || email.length > MAX_EMAIL_LEN || !EMAIL_RE.test(email)) {
return redirect(REDIRECT_ERROR);
}

const sourcePath = sourcePathFromReferer(request.headers.get("Referer"));

try {
await env.DB.prepare(
"INSERT INTO interests (email, source_path) VALUES (?, ?)"
)
.bind(email, sourcePath)
.run();
} catch (err) {
// Log to Cloudflare logs but don't surface DB internals to the user.
console.error("[subscribe] insert failed:", err);
return redirect(REDIRECT_ERROR);
}

return redirect(REDIRECT_THANKS);
};

// Reject other methods so a stray GET doesn't 404 ambiguously.
export const onRequest: PagesFunction<Env> = async ({ request }) => {
return new Response(`Method ${request.method} not allowed`, {
status: 405,
headers: { Allow: "POST" },
});
};
Loading