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
29 changes: 17 additions & 12 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A modernization of [laddr](https://github.com/CodeForPhilly/laddr) (the platform
These auto-trigger by topic — you don't load them manually. Mentioned here so you know what's already covered and don't duplicate.

| Skill | Triggers on | What it covers |
|---|---|---|
| --- | --- | --- |
| [`specops`](./skills/specops/SKILL.md) | `specs/`, `plans/`, "spec", "closeout commit", new features | Spec-driven workflow (specs are source of truth), plans-as-micro-DAG protocol, closeout commit ritual, follow-ups taxonomy, spec-drift auditor |
| [`backend-fastify`](./skills/backend-fastify/SKILL.md) | New routes, services, plugins, env vars | Fastify 5 patterns, plugin ordering, `@fastify/env` validation, error handling |
| [`frontend-shadcn`](./skills/frontend-shadcn/SKILL.md) | New screens, components, routing, styling | Vite + React 19 + shadcn/ui + Tailwind v4 + React Router v7 patterns |
Expand Down Expand Up @@ -64,19 +64,23 @@ codeforphilly-data ─── Public data store. Branches:
│ merge target). Hot-reload webhook
│ fires on push.
cfp-sandbox-cluster ─── GitOps repo (hologit-projected) that pulls
│ this repo's `deploy/kustomize/` upstream
│ and applies it via Kustomize. See
cfp-sandbox-cluster ─── GitOps repos (hologit-projected) that pin a
cfp-live-cluster │ release tag of this repo, project its
│ `deploy/kustomize/base/` and apply it via
│ Kustomize. Sandbox = next-v2.codeforphilly.org
│ (ns codeforphilly-rewrite-sandbox); prod =
│ next.codeforphilly.org → codeforphilly.org at
│ cutover (ns codeforphilly-ng). See
│ `docs/operations/deploy.md`.
```

Operator docs in [`docs/operations/`](../docs/operations/): `deploy.md` for the cluster topology, `sandbox-deploy.md` for the manual procedure, `runbook.md` for incident response (including the hot-reload webhook).
Operator docs in [`docs/operations/`](../docs/operations/): `deploy.md` for the cluster topology + GitOps flow, `releases.md` for cutting a release, `sandbox-deploy.md` for the sandbox specifics, `runbook.md` for incident response (including the hot-reload webhook), `cutover.md` for the production hostname move.

## Stack

- **Backend** — Fastify 5.x + TypeScript. Single replica, in-process write mutex.
- **Public storage** — [gitsheets](https://github.com/JarvusInnovations/gitsheets) (TOML records in a git repo). Public-by-design — civic transparency. No persistent OLTP. See [specs/behaviors/storage.md](../specs/behaviors/storage.md).
- **Private storage** — S3-compatible bucket holding `.jsonl` files (private profiles + legacy password hashes). Boot-load + in-memory; PUT on mutation. See [specs/behaviors/private-storage.md](../specs/behaviors/private-storage.md). Real production private data never lands on a dev machine.
- **Private storage** — `.jsonl` files (private profiles + legacy password hashes) on a filesystem PVC in every deployed environment (`STORAGE_BACKEND=filesystem`); an S3-compatible backend exists but is unused. Boot-load + in-memory; rewrite on mutation. See [specs/behaviors/private-storage.md](../specs/behaviors/private-storage.md). Real production private data never lands on a dev machine.
- **Schemas** — Zod in `packages/shared`, consumed by both web and api, validating records in both stores.
- **Full-text search** — in-memory SQLite FTS5 (or MiniSearch fallback), rebuilt at boot from gitsheets state.
- **Auth** — GitHub OAuth as the sole primary identity provider; stateless JWT sessions. We're also the SAML IdP for codeforphilly.slack.com. See [specs/api/auth.md](../specs/api/auth.md), [specs/api/saml.md](../specs/api/saml.md).
Expand Down Expand Up @@ -135,23 +139,24 @@ npm run -w apps/web dev # web only

## Deploying

GitOps. This repo publishes a Docker image to GHCR; the [`cfp-sandbox-cluster`](https://github.com/CodeForPhilly/cfp-sandbox-cluster) repo pulls our [`deploy/kustomize/`](../deploy/kustomize/) upstream via hologit and applies it with Kustomize. Production will follow the same pattern under a `cfp-prod-cluster` repo.
GitOps. Releases publish a Docker image to GHCR; two cluster repos — [`cfp-sandbox-cluster`](https://github.com/CodeForPhilly/cfp-sandbox-cluster) (sandbox) and [`cfp-live-cluster`](https://github.com/CodeForPhilly/cfp-live-cluster) (production, alongside legacy laddr) — each pin a release tag, project our [`deploy/kustomize/base/`](../deploy/kustomize/base/) via hologit and apply it with Kustomize.

Typical change flow:

1. **Merge to `main`** — CI builds + tests; nothing deploys yet.
2. **Publish image** (currently manual) — `docker build --platform=linux/amd64 -t ghcr.io/codeforphilly/codeforphilly-ng:sandbox . && docker push …`. Apple-silicon dev machines must set the platform flag — cluster nodes are amd64.
3. **GitOps pickup** — `cfp-sandbox-cluster` projects from our `deploy/kustomize/`; on its own merge, applies via `kubectl apply -k`.
1. **Merge to `develop`** — CI builds + tests; pushing `develop` opens/updates a `Release: vX.Y.Z` PR into `main` (see `docs/operations/releases.md` and the `release-flow` skill).
2. **Publish image** — merging the Release PR tags `vX.Y.Z`; `container-publish.yml` builds and pushes `ghcr.io/codeforphilly/codeforphilly-ng:vX.Y.Z` (+ `:latest`). No manual `docker push`.
3. **GitOps pickup** — in the cluster repo, bump `.holo/sources/codeforphilly-ng.toml` (`ref = "refs/tags/vX.Y.Z"`) and `images[].newTag` in `codeforphilly-ng/app/kustomization.yaml` together; merge to `main`; "Build k8s-manifests" projects to `releases/k8s-manifests`; a bot PR into `deploys/k8s-manifests` applies on merge. Sandbox first, then live.
4. **Pod boot** — single replica, `Recreate` strategy. Container entrypoint bare-clones the data repo on every pod start (the data volume is `emptyDir`, so first boot = every fresh pod). Node boots: env → store load → **reconcile** (ff/replay/escape-hatch against `origin/<CFP_DATA_BRANCH>`) → **push daemon** → routes → SPA. `/api/health/ready` returns 200 once stores are loaded *and* reconciled.
5. **Live data updates** — independent of app deploy. Pushes to `published` trigger the [hot-reload webhook](../docs/operations/runbook.md#hot-reload-webhook); the pod rebuilds in-memory state in place, no restart.

Constraints worth knowing before touching anything deploy-shaped:

- **Single replica is non-negotiable** — writes flow through an in-process mutex; horizontal scale would lose write serialization.
- **One image for API + SPA** — Fastify serves the built `apps/web/dist` as fallthrough. No separate web container.
- **Sealed secrets are cluster-bound** — sandbox-sealed secrets can't decrypt in prod. Plain values live in `.env` locally; cluster values live in the sandbox-cluster repo's `codeforphilly-ng.secrets/`.
- **Sealed secrets are cluster-bound** — sandbox-sealed secrets can't decrypt in prod. Plain values live in `.env` locally; cluster values live in each cluster repo's `codeforphilly-ng.secrets/`.
- **Cutover is a hostname move, not a DNS change** — the apex already resolves to the live cluster's gateway; T-0 is one commit in `cfp-live-cluster` moving listeners from `_gateways/code-for-philly.yaml` to `_gateways/codeforphilly-ng.yaml`.

Full operator docs: [`docs/operations/`](../docs/operations/) — `deploy.md` (boot sequence + env table), `sandbox-deploy.md` (manual bring-up), `secrets.md` (the secret contract), `runbook.md` (incident response + hot-reload webhook), `cutover.md` (production switch plan).
Full operator docs: [`docs/operations/`](../docs/operations/) — `deploy.md` (environments, GitOps flow, boot sequence, env table), `releases.md` (cutting a release), `sandbox-deploy.md` (sandbox specifics + manual escape hatch), `secrets.md` (the secret contract), `runbook.md` (incident response + hot-reload webhook), `cutover.md` (production hostname move), `legacy-credentials-import.md` (private-store seeding).

## Source control

Expand Down
69 changes: 30 additions & 39 deletions docs/operations/cutover-announcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ same accounts. The active cutover is about an hour. Expect ~5 minutes of
What changes for you:
- :white_check_mark: All your URLs keep working (legacy redirects in place)
- :white_check_mark: Your Slack identity is preserved automatically
- :arrows_counterclockwise: First sign-in after cutover routes you through
GitHub OAuth + a one-click "claim your account" flow
- :no_entry_sign: Username/password sign-in is going away — GitHub is the
primary login from cutover forward
- :white_check_mark: Your existing username + password keep working
- :arrows_counterclockwise: You can also "Sign in with GitHub" — if your
GitHub email matches your CFP email your account links automatically;
otherwise a one-click claim flow does it. We'd love you to link GitHub,
but there's no deadline

What we need from you BEFORE cutover:
- Hold off on edits to your profile / projects starting {{ freeze_date }}
(we're freezing writes to make the migration clean)
- Make sure you remember which GitHub account is associated with your CFP
email — your first sign-in needs to come from that GitHub account
- If you plan to sign in with GitHub, check which GitHub account uses your
CFP email — matching emails link automatically

If you have questions: drop them in this thread or DM @{{ cutover_lead_slack }}.
```
Expand All @@ -46,15 +47,16 @@ On {{ cutover_date_long }} we're moving codeforphilly.org to a new platform.
What's changing
- The site is being rebuilt on a modern stack — same look, same URLs,
same projects.
- Sign-in is moving to GitHub OAuth. Your password is no longer needed.
- Your existing username and password keep working. You can also sign in
with GitHub and link the two.
- Your Slack identity is preserved automatically.

What you need to do
- Nothing right now — but please don't edit your profile or projects
between {{ freeze_date }} and {{ cutover_date_short }}.
- After cutover, sign in via the new "Sign in with GitHub" button. If
your CFP email matches your GitHub email, your account is claimed
automatically. If not, follow the on-screen claim flow.
- After cutover, sign in as usual, or try the new "Sign in with GitHub"
button. If your CFP email matches your GitHub email, your account is
linked automatically. If not, follow the on-screen claim flow.

When it happens
- {{ cutover_date_long }} starting at {{ cutover_time }}.
Expand All @@ -65,10 +67,14 @@ Questions? Reply to this email or ping us in Slack.
— Code for Philly
```

## T-0: maintenance page
## T-0: maintenance page (optional)

The static page served from the legacy site while DNS propagates. Plain HTML;
no JavaScript needed.
A static page for the legacy site during the hostname move. Cutover is a
gateway-listener change with no DNS propagation
([cutover.md → T-0](cutover.md#t-0-cutover)), so the switch is effectively
instant per hostname and this page is optional — use it if you want a
visible "hold on" while the final data delta runs. Plain HTML; no
JavaScript needed.

```html
<!doctype html>
Expand Down Expand Up @@ -120,10 +126,18 @@ https://github.com/CodeForPhilly/codeforphilly-ng/issues or just reply
here.
```

## T+90 days: unclaimed-account reminder
## Unclaimed-account reminder (not scheduled)

Sent automatically by `apps/api/scripts/cutover-mailout.ts`. The template is
in code — see `buildEmailBody()` in that file. Reproduced here for review:
`apps/api/scripts/cutover-mailout.ts` can email members whose laddr account
has not yet been linked to GitHub. **It is not on the cutover timeline.**
Per [account-migration.md](../../specs/behaviors/account-migration.md#sunset-deferred)
legacy password sign-in has no deadline, so there is nothing to remind
people *of*; the only nudge is the "Connect GitHub" banner on `/account`.
If a future spec change sets a sunset date, this template is the starting
point — until then, don't send it. The template is in code — see
`buildEmailBody()` in that file. Reproduced here for review (note the last
paragraph about retiring accounts has no backing in the spec and would need
one before any send):

Subject: `Action needed: claim your Code for Philly account`

Expand All @@ -144,29 +158,6 @@ unclaimed for one year may be retired.
— Code for Philly
```

## T+180 days: password-credential drain notice

A short Slack post in `#announcements` and an info-only email to remaining
unclaimed Persons. Honest about the irreversible step.

### Slack

```
:warning: Six months post-cutover

It's been six months since we moved to the new platform. We're cleaning up
the remaining legacy password records — about {{ count }} accounts.

If you signed in via GitHub at any point: you're already migrated, this
doesn't affect you.

If you have a laddr account you haven't logged into yet: please log in
in the next 14 days. After that, you'll need to contact a staff member
to verify your identity before reclaiming the account.

Questions: ping @{{ cutover_lead_slack }}.
```

## Localization

We don't send in any language other than English at v1. If we ever expand
Expand Down
Loading