Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
648a280
chore(workspace): pin Node 22.22.3 via asdf
themightychris May 16, 2026
dc7766b
chore(workspace): scaffold npm workspaces root
themightychris May 16, 2026
824e7b1
chore(api): add Fastify workspace skeleton
themightychris May 16, 2026
3a2e6ef
chore(api): install fastify + tsx + typescript + pino-pretty
themightychris May 16, 2026
361d96f
feat(api): scaffold Fastify app with /api/health
themightychris May 16, 2026
5649363
chore(web): add Vite/React workspace skeleton
themightychris May 16, 2026
942a563
chore(web): install react + vite + typescript
themightychris May 16, 2026
e14a3dc
fix(web): persist react / react-dom in workspace deps
themightychris May 16, 2026
be3abb5
feat(web): scaffold Vite + React placeholder
themightychris May 16, 2026
3eb054b
chore(shared): scaffold packages/shared placeholder
themightychris May 16, 2026
d9277bd
chore: install concurrently for root dev script
themightychris May 16, 2026
431af7b
chore: install eslint + typescript-eslint + react-hooks
themightychris May 16, 2026
6b5fdfd
chore(workspace): add ESLint flat config
themightychris May 16, 2026
c58f215
ci: add GitHub Actions workflow
themightychris May 16, 2026
1c39c04
fix(workspace): don't gitignore seeded private-storage fixture
themightychris May 16, 2026
293f799
chore(workspace): drop redundant exactOptionalPropertyTypes flag
themightychris May 16, 2026
0d3ac74
chore: move CLAUDE.md under .claude/
themightychris May 16, 2026
73c4f09
docs: add root README.md
themightychris May 16, 2026
d2d32b0
docs(plans): document done-state plan-update convention
themightychris May 16, 2026
ffbffe2
chore(plans): mark workspace done (PR #9)
themightychris May 16, 2026
221805b
docs(plans): drop redundant status table + DAG, add follow-ups
themightychris May 16, 2026
94e57ff
chore(plans): add Follow-ups section to workspace plan
themightychris May 16, 2026
23d496d
docs(plans): require absorbing deferrals into downstream plans
themightychris May 16, 2026
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
51 changes: 30 additions & 21 deletions CLAUDE.md → .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ Workflow:
3. Implement to match the spec
4. Verify running software matches the spec

Start at [specs/README.md](specs/README.md). The index of what's where:
Start at [specs/README.md](../specs/README.md). The index of what's where:

- [specs/architecture.md](specs/architecture.md) — stack, repo layout, deploy
- [specs/data-model.md](specs/data-model.md) — entities, fields, relationships
- [specs/deferred.md](specs/deferred.md) — features intentionally out of scope (do NOT silently implement these)
- [specs/api/](specs/api/) — endpoint contracts (one file per resource group)
- [specs/screens/](specs/screens/) — one file per route — what the user sees, what they can do
- [specs/behaviors/](specs/behaviors/) — cross-cutting rules referenced from multiple screens/APIs
- [specs/architecture.md](../specs/architecture.md) — stack, repo layout, deploy
- [specs/data-model.md](../specs/data-model.md) — entities, fields, relationships
- [specs/deferred.md](../specs/deferred.md) — features intentionally out of scope (do NOT silently implement these)
- [specs/api/](../specs/api/) — endpoint contracts (one file per resource group)
- [specs/screens/](../specs/screens/) — one file per route — what the user sees, what they can do
- [specs/behaviors/](../specs/behaviors/) — cross-cutting rules referenced from multiple screens/APIs

## Spec drift auditing

Expand All @@ -30,11 +30,20 @@ Run `/audit-spec-drift` to launch a comprehensive audit comparing `specs/` again

`plans/` is the micro-DAG of work that bridges `specs/` to the running code. If specs describe **state** (what should be true forever), plans describe **motion** (how we get there). Start a feature with a plan; the plan declares its scope, the specs it implements, its dependencies, and concrete validation criteria.

Plans index: [plans/README.md](plans/README.md). Workflow:
Plans index: [plans/README.md](../plans/README.md). Workflow:

1. **Add a plan** when starting a new chunk of work. `status: planned` + `depends:` set.
2. **Move to `in-progress`** when you start. Multiple plans can be in-progress in parallel across people, but one plan per contributor at a time is the norm.
3. **Move to `done`** when validation criteria all pass. Link the merged PR in frontmatter (`pr: 42`).
2. **Move to `in-progress`** when you start, as the first commit on the branch (`chore(plans): mark <slug> in-progress`). Skippable for tiny plans — going straight to `done` at the end is fine. Multiple plans can be in-progress in parallel across people, but one plan per contributor at a time is the norm.
3. **Move to `done` as the last commit on the branch, before merge.** That commit does the following, all in one shot, with message `chore(plans): mark <slug> done (PR #<n>)`:
- Frontmatter: `status` → `done`, add `pr: <PR number>` (knowable once `gh pr create` returns)
- **Validation checklist: flip each `- [ ]` to `- [x]` for criteria you verified.** If a criterion can't be verified at merge time (depends on a downstream plan, needs production deploy, etc.), leave it unchecked and add a one-line note in the Notes section explaining why and where it'll close out. Never silently rewrite a validation criterion to match what you ended up doing — that's a plan amendment in its own earlier commit.
- **Notes** section: non-actionable carry-forwards — decisions, surprises, gotchas, learnings. Things future-you would want to know.
- **Follow-ups** section: actionable items that didn't ship with this plan. Each entry is one of:
- `Issue [#N](link) — short description` — when actionable and not owned by an existing planned-or-in-progress plan, file the issue first (`gh-axi issue create`) and link it
- `Deferred to [`<other-plan>`](<other-plan>.md) — short description` — when an unstarted (`status: planned`) downstream plan should own the work. **The same closeout commit must also edit that downstream plan to absorb the deferral** — typically a new bullet under Approach and a new criterion under Validation. If the downstream plan is already `in-progress` or `done`, use the Issue shape instead; never modify a plan that's actively being implemented or already frozen.
- `Tracked as: <free-form pointer>` — for anything else (waiting on community input, vendor response, etc.)
- `None.` — explicit when there's nothing, so a future reader can see the section was considered, not just absent
- The plan is frozen after merge — historical record, no further edits
4. **Update `depends:`** as the DAG sharpens — a plan can discover it needs a new prereq mid-stream.
5. **Specs come first.** A plan implements specs that already exist. If you realize specs need to change mid-plan, the spec change is its own PR before the plan continues.
6. **Splitting a plan**: rename and add the new one with `depends:` updated.
Expand All @@ -58,21 +67,21 @@ pr: 42 # merged PR once done (optional)

`specs:` is for specs we own — the spec-drift-auditor matches them against implementation. `upstream-specs:` is for specs owned by dependencies (e.g., gitsheets) that this plan consumes; they're informational only and the spec-drift-auditor doesn't check them. Use the `<repo>:<path>` form so it's obvious where to look.

A plan's body follows the template in [plans/README.md](plans/README.md): Scope, Implements, Approach, Validation, Risks/unknowns, Notes. The Validation section is the load-bearing part — it converts "in-progress" to "done."
A plan's body follows the template in [plans/README.md](../plans/README.md): Scope, Implements, Approach, Validation, Risks/unknowns, Notes, Follow-ups. The Validation section is the load-bearing part — it converts "in-progress" to "done."

**Plans are not specs.** They're project-management artifacts. Plans rot fast — once a plan is `done`, it's a historical record; don't keep editing it. The `spec-drift-auditor` reads `specs/`, not `plans/`.

## 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 two `.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).
- **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 two `.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).
- **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 are 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).
- **Auth** — GitHub OAuth as the sole primary identity provider; stateless JWT sessions. We are 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).
- **Frontend** — Vite + React 19 + shadcn/ui + Tailwind v4 + React Router v7.

See [specs/architecture.md](specs/architecture.md) for the full stack rationale.
See [specs/architecture.md](../specs/architecture.md) for the full stack rationale.

Per the user's global rules: `npm` workspaces (not bun), `asdf` manages the Node version, commit lockfiles.

Expand All @@ -82,7 +91,7 @@ Per the user's global rules: `npm` workspaces (not bun), `asdf` manages the Node
2. **Private bucket** — emails, newsletter prefs, legacy password hashes during migration. Production-only; devs use a local filesystem backend with seeded fakes.
3. **Public snapshot** (`codeforphilly-data-snapshot`) — anonymized, contributor-cloneable copy of the public data. PII-free by construction.

**Real production private data never lands on a dev machine** — see [specs/behaviors/private-storage.md](specs/behaviors/private-storage.md).
**Real production private data never lands on a dev machine** — see [specs/behaviors/private-storage.md](../specs/behaviors/private-storage.md).

## Tooling

Expand Down Expand Up @@ -115,9 +124,9 @@ npm run -w apps/web dev
- Field names: `camelCase` in TS and in TOML records. No casing translation.
- IDs: UUIDv7. Slugs (not IDs) in user-facing URLs.
- Timestamps: ISO 8601 UTC strings (e.g., `"2026-05-15T18:42:00Z"`) — in requests, responses, and on disk.
- Use the response envelope from [specs/api/conventions.md](specs/api/conventions.md) for every endpoint.
- Markdown is rendered server-side. Clients never run a markdown library on user content. See [specs/behaviors/markdown-rendering.md](specs/behaviors/markdown-rendering.md).
- Mutations go through the in-process write mutex documented in [specs/behaviors/storage.md](specs/behaviors/storage.md). Don't write to the data repo from anywhere else.
- Use the response envelope from [specs/api/conventions.md](../specs/api/conventions.md) for every endpoint.
- Markdown is rendered server-side. Clients never run a markdown library on user content. See [specs/behaviors/markdown-rendering.md](../specs/behaviors/markdown-rendering.md).
- Mutations go through the in-process write mutex documented in [specs/behaviors/storage.md](../specs/behaviors/storage.md). Don't write to the data repo from anywhere else.

## Source control

Expand All @@ -131,8 +140,8 @@ npm run -w apps/web dev

We are migrating from a MySQL-backed PHP/Emergence app to a gitsheets-backed Node app. Every user-facing URL stays the same. See:

- [specs/behaviors/slug-handles.md](specs/behaviors/slug-handles.md) — slug format and uniqueness
- [specs/behaviors/legacy-id-mapping.md](specs/behaviors/legacy-id-mapping.md) — `legacyId` column and URL redirects
- [specs/behaviors/slug-handles.md](../specs/behaviors/slug-handles.md) — slug format and uniqueness
- [specs/behaviors/legacy-id-mapping.md](../specs/behaviors/legacy-id-mapping.md) — `legacyId` column and URL redirects
- The one-shot importer lives at `apps/api/scripts/import-laddr.ts` (not yet implemented)

## When in doubt
Expand Down
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install asdf-managed tools
uses: asdf-vm/actions/install@v4

- name: Install dependencies
run: npm ci

- name: Type check
run: npm run type-check

- name: Lint
run: npm run lint

- name: Build
run: npm run build
30 changes: 30 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Dependencies
node_modules/

# Build artifacts
dist/
build/
*.tsbuildinfo

# Local environment + secrets
.env
.env.*
!.env.example
*.local
*.local.*

# Dev private storage runtime dir (filesystem backend; see specs/behaviors/private-storage.md).
# The seeded fixture at fixtures/private-storage-seeded/ is intentionally NOT ignored —
# it ships in the code repo so contributors can load a fake-data baseline.
private-storage/

# Editor / OS
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.shared.json
.idea/

# Logs
*.log
npm-debug.log*
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 22.22.3
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# codeforphilly-rewrite

A modernization of [laddr](https://github.com/CodeForPhilly/laddr) — the platform behind [codeforphilly.org](https://codeforphilly.org) — onto a Fastify + Vite/React + [gitsheets](https://github.com/JarvusInnovations/gitsheets) stack.

This site is **spec-driven**: [`specs/`](specs/) declares what should be true and the implementation is brought into conformance with it. Plans in [`plans/`](plans/) are the bridge between specs and code.

## Quick links

- [`specs/README.md`](specs/README.md) — what specs cover, how they're authored, where to start
- [`specs/architecture.md`](specs/architecture.md) — stack, repo layout, deploy
- [`plans/README.md`](plans/README.md) — the work-in-flight DAG that gets us to spec-complete
- [`.claude/CLAUDE.md`](.claude/CLAUDE.md) — authorship conventions, tooling rules, source-control norms

## Getting started

```bash
asdf install
npm ci
npm run dev # api (:3001) + web (:5173) in parallel
```

Root scripts:

| Command | Effect |
| ------- | ------ |
| `npm run dev` | Concurrent dev servers for `apps/api` + `apps/web` with watch + HMR |
| `npm run build` | Builds every workspace |
| `npm run type-check` | `tsc --noEmit` across all workspaces |
| `npm run lint` | ESLint flat-config at root |

## Stack

- **Backend** — Fastify 5.x + TypeScript, single replica, in-process write mutex
- **Public storage** — gitsheets (TOML records in a git repo, civic-transparency public)
- **Private storage** — S3-compatible bucket (emails, newsletter prefs, legacy password hashes during migration)
- **Frontend** — Vite + React 19 + shadcn/ui + Tailwind v4 + React Router v7
- **Auth** — GitHub OAuth + stateless JWT sessions; we are also the SAML IdP for codeforphilly.slack.com

See [`specs/architecture.md`](specs/architecture.md) for the rationale on each choice.

## Contributing

Spec-first: before writing or changing code, read the relevant spec. If the spec doesn't cover what you're about to do, update the spec first. See [`specs/README.md`](specs/README.md) for the workflow and [`.claude/CLAUDE.md`](.claude/CLAUDE.md) for conventions.
22 changes: 22 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@cfp/api",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"fastify": "^5.8.5"
},
"devDependencies": {
"@types/node": "^25.8.0",
"pino-pretty": "^13.1.3",
"tsx": "^4.22.0",
"typescript": "^6.0.3"
}
}
20 changes: 20 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Fastify from 'fastify';

const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? '0.0.0.0';

const app = Fastify({
logger:
process.env.NODE_ENV === 'production'
? true
: { transport: { target: 'pino-pretty' } },
});

app.get('/api/health', () => ({ status: 'ok' }));

try {
await app.listen({ port: PORT, host: HOST });
} catch (err) {
app.log.error(err);
process.exit(1);
}
13 changes: 13 additions & 0 deletions apps/api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2023",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*"]
}
12 changes: 12 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code for Philly</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
23 changes: 23 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@cfp/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview",
"type-check": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^6.0.3",
"vite": "^8.0.13"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
}
}
8 changes: 8 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function App() {
return (
<main>
<h1>Hello, Code for Philly</h1>
<p>The site is being rebuilt. See you soon.</p>
</main>
);
}
14 changes: 14 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

const container = document.getElementById('root');
if (!container) {
throw new Error('Root container #root not found in index.html');
}

createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
13 changes: 13 additions & 0 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
18 changes: 18 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: false,
},
},
},
});
Loading