',
+ MessageStream: 'outbound',
+ });
+ });
+});
From 6a569b65660cc0697733e5678bd21eb8ba50b05c Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 19:32:06 -0400
Subject: [PATCH 10/64] chore(plans): mark postmark-notifier done (PR #158)
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
plans/postmark-notifier.md | 135 +++++++++++++++++++++++++++++++++++++
1 file changed, 135 insertions(+)
create mode 100644 plans/postmark-notifier.md
diff --git a/plans/postmark-notifier.md b/plans/postmark-notifier.md
new file mode 100644
index 0000000..f2506db
--- /dev/null
+++ b/plans/postmark-notifier.md
@@ -0,0 +1,135 @@
+---
+status: done
+depends: [notifier-email]
+specs:
+ - specs/architecture.md
+issues: []
+pr: 158
+---
+
+# Plan: Postmark email transport
+
+## Scope
+
+Replace the Resend-backed email transport behind `EmailNotifier` with Postmark.
+Project owner's call: "I want to keep using Postmark, resend was a random agent
+choice." Postmark is the provider the legacy laddr site already sends through,
+so the `codeforphilly.org` sender signature is verified there and no new
+vendor account or DNS work is needed.
+
+**In scope:**
+
+- Spec + operator docs describe Postmark as the transactional email provider
+ and the env surface it needs.
+- A provider-neutral `EmailTransport` seam under `EmailNotifier`, with a
+ Postmark adapter as the only vendor-aware file.
+- Env rename `RESEND_API_KEY` → `POSTMARK_SERVER_TOKEN`, plus
+ `POSTMARK_MESSAGE_STREAM` (default `outbound`).
+- The T+90 `cutover-mailout` script sends through the same adapter.
+- Tests for the adapter's field mapping and the SDK's wire format.
+
+**Out of scope:**
+
+- Any change to the `Notifier` interface, templates, or the fallback-to-
+ `LoggingNotifier` / log-not-throw semantics established by
+ [`notifier-email`](notifier-email.md).
+- Sealing `POSTMARK_SERVER_TOKEN` in the cluster repo — operator step, tracked
+ under Follow-ups.
+- Bounce/complaint webhooks and Slack DM — still the follow-ups recorded on
+ [`notifier-email`](notifier-email.md) and #95.
+
+## Implements
+
+- [architecture.md](../specs/architecture.md) — "Email: **Postmark**" in the
+ stack table; `POSTMARK_SERVER_TOKEN` / `POSTMARK_MESSAGE_STREAM` /
+ `CFP_NOTIFICATION_FROM` in the env table. The behaviours the notifier
+ serves ([help-wanted-roles.md](../specs/behaviors/help-wanted-roles.md),
+ [projects-help-wanted.md](../specs/api/projects-help-wanted.md),
+ [auth.md](../specs/api/auth.md)) are transport-agnostic and unchanged.
+
+## Approach
+
+1. **Dependency swap first, alone.** `npm install -w apps/api postmark` then
+ `npm uninstall -w apps/api resend`, committed on their own with the exact
+ commands in the body.
+2. **Specs and docs before code.** `specs/architecture.md`, `specs/deferred.md`,
+ `docs/operations/{secrets,deploy,cutover,cutover-announcement}.md`, and the
+ `deploy/kustomize/base/configmap.yaml` comment. Plans that mention Resend
+ (`notifier-email`, `welcome-notification`, `test-harness`, `write-api`,
+ `cutover-prep`, `login-migration-impl-phase-c`) are all frozen `done` and
+ stay as-is.
+3. **Introduce the seam.** `apps/api/src/notify/transport.ts` declares
+ `OutboundEmail` (from/to/subject/text/html) and `EmailTransport.send()` →
+ `{ messageId }`, throwing on any failure. `EmailNotifier` takes a
+ `transport` instead of a Resend client; its four near-identical send blocks
+ collapse into one `#deliver(label, ctx, to, tpl)` with a single catch.
+4. **Postmark adapter.** `apps/api/src/notify/postmark-transport.ts` wraps a
+ `PostmarkSender` (the `sendEmail` slice of `ServerClient`), maps onto
+ Postmark's PascalCase `Message`, and stamps `MessageStream`. Boot wiring in
+ `plugins/services.ts` builds `new ServerClient(POSTMARK_SERVER_TOKEN)` only
+ when the token is set; otherwise `LoggingNotifier` exactly as before.
+5. **Cutover script** reuses `PostmarkTransport` in place of its hand-rolled
+ Resend `fetch`.
+6. **Tests.** `email-notifier.test.ts` stubs the seam with `vi.fn()`. New
+ `postmark-transport.test.ts` checks the field mapping with a stub client and
+ runs the real `ServerClient` against an MSW intercept of
+ `POST https://api.postmarkapp.com/email` (`createPostmarkMock`, replacing
+ `createResendMock` in `tests/helpers/mocks.ts`).
+
+## Validation
+
+- [x] `grep -rniE resend specs docs plans apps packages deploy .env.example README.md` hits only frozen `done` plans.
+- [x] `EmailNotifier` sends `{ from, to, subject, text, html }` through the transport and returns `delivered: true` with the provider message id logged.
+- [x] Missing recipient → `delivered: false`, no transport call, warning logged (all four notification kinds).
+- [x] Transport throwing (network blip or Postmark rejection carrying `code`/`statusCode`) → `delivered: false`, error logged, nothing thrown to the caller.
+- [x] `PostmarkTransport` maps onto `{ From, To, Subject, TextBody, HtmlBody, MessageStream }`, defaults `MessageStream` to `outbound`, honours an override, and propagates SDK errors untouched.
+- [x] Real `ServerClient` over MSW POSTs that exact JSON body to `https://api.postmarkapp.com/email` and surfaces `MessageID`.
+- [x] When `POSTMARK_SERVER_TOKEN` is unset the services plugin installs `LoggingNotifier` — every pre-existing API test passes unchanged.
+- [x] `POSTMARK_MESSAGE_STREAM` defaults to `outbound` in both the Zod schema and the `@fastify/env` JSON schema.
+- [x] `import { ServerClient } from 'postmark'` resolves under plain Node ESM (Postmark ships CJS; verified with `node --input-type=module`).
+- [x] `npm run type-check && npm run lint && npm test` clean: api 427/427, web 89/89, shared 75/75.
+
+## Risks / unknowns
+
+- **Message stream must exist on the server.** Postmark 422s a send whose
+ `MessageStream` is unknown to that server. Default `outbound` exists on every
+ server; anyone overriding it must create the stream first. Documented in
+ `docs/operations/secrets.md`.
+- **Inactive recipients.** Postmark refuses to send to addresses it has
+ previously hard-bounced or that complained (`InactiveRecipientsError`, code
+ 406). Same `delivered: false` path as any failure; the logged `err` carries
+ the code so operators can spot it.
+- **CJS interop.** The `postmark` package is CommonJS with no `exports` map;
+ named ESM imports rely on Node's cjs-module-lexer detecting
+ `exports.ServerClient = …`. Verified for 5.1.0; a future SDK build that
+ switches to `Object.defineProperty`-only exports would need a default import.
+
+## Notes
+
+- **Only one failure shape now.** Resend's SDK could throw *or* resolve with
+ `{ error }`; Postmark's throws a `PostmarkError` subclass on every non-2xx.
+ That let the notifier drop its per-method `if (result.error)` branches and
+ share a single `#deliver`. The `err` logged carries `code` + `statusCode`, so
+ the "was it the network or the provider" distinction the old two-branch log
+ gave operators is preserved in the structured field rather than the message.
+- **`MessageSendingResponse` is not a top-level export.** It lives under the
+ `Models` namespace (`import type { Message, Models } from 'postmark'`);
+ `Message` itself is top-level.
+- **Stale local `node_modules` masqueraded as a type-check failure.** The first
+ gate run failed in `apps/web` on a missing `marked` that was already in the
+ lockfile on `develop`; `npm install` (no lockfile change) fixed it. Not
+ related to this plan, noted so the next person doesn't chase it.
+- **`createResendMock` had no callers.** It was harness scaffolding from
+ [`test-harness`](test-harness.md); renamed to `createPostmarkMock` and given
+ its first real consumer in `postmark-transport.test.ts`.
+- **`--body-file` is not a `gh-axi pr create` flag.** Pass `--body "$(cat …)"`.
+
+## Follow-ups
+
+- Tracked as: seal `POSTMARK_SERVER_TOKEN` (and optionally
+ `POSTMARK_MESSAGE_STREAM`) in `cfp-sandbox-cluster` `codeforphilly-ng.secrets/`
+ per `docs/operations/secrets.md`; delete any `RESEND_API_KEY` sealed secret
+ that was created. Until sealed, the pod keeps logging instead of sending.
+- Bounce / complaint webhooks, PII redaction in notifier logs, and the Slack DM
+ channel remain as recorded on [`notifier-email`](notifier-email.md) — Postmark
+ offers the same webhook hooks, so nothing about those follow-ups changes.
From 5dbde9ef51660b0aad33a0dd16e09ed42080dd20 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 19:14:55 -0400
Subject: [PATCH 11/64] docs(specs): hot reload must rebuild every in-memory
index
The hot-reload section described the rebuild as "mutate the live Maps
in place" without saying which Maps. The implementation had quietly
skipped three secondary indices (legacy-id, buzz-by-slug, slug-history),
which is exactly the gap an unqualified sentence leaves open. State the
invariant explicitly: every collection on the live state, primary and
secondary, is replaced from the fresh one, so no lookup path can serve
pre-reload contents after a reload.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
specs/behaviors/storage.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/specs/behaviors/storage.md b/specs/behaviors/storage.md
index 2ff0edd..334668e 100644
--- a/specs/behaviors/storage.md
+++ b/specs/behaviors/storage.md
@@ -332,7 +332,7 @@ A push to the configured `CFP_DATA_BRANCH` from outside the API (typically a mer
- **Reconcile + rebuild** — otherwise acquire the data-repo lock, call the same reconciliation state machine the boot path uses (`fastify.reconcileDataRepo`), and:
- If outcome is `'in-sync'`, skip the rebuild and return 200 noChanges with the outcome.
- Otherwise rebuild the in-memory state and FTS index from the new tree, then return 200 with the outcome, the old and new commit, and `rebuilt: true`.
-- **Atomicity** — the rebuild constructs a fresh `InMemoryState` first; only after that succeeds does it mutate the live Maps in place. The FTS engine exposes a `reload(state)` that drops and re-inserts every FTS5 table. If the rebuild throws partway, the route returns 500 and the operator should restart the pod.
+- **Atomicity** — the rebuild constructs a fresh `InMemoryState` first; only after that succeeds does it mutate the live Maps in place. **Every** collection on the live state is replaced from the fresh one — the primary entity maps and every secondary index, including the legacy-id, buzz-by-slug, and slug-history indices — so no lookup path can serve pre-reload contents after a reload. The FTS engine exposes a `reload(state)` that drops and re-inserts every FTS5 table. If the rebuild throws partway, the route returns 500 and the operator should restart the pod.
- **Concurrency** — uses the same `dataRepoLock` as boot reconciliation, so a webhook fires can't race a `transact`-driven write.
The GitHub Actions workflow that calls this endpoint lives in the `codeforphilly-data` repo (`.github/workflows/notify-deployments.yml`), not in this app repo. It fires on push to `CFP_DATA_BRANCH` and posts `{ branch, commitHash: }` with the secret as a bearer token.
From 3e3c48ac0c5a15974e6ad3c17f02fc0dabeb3a47 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 19:15:47 -0400
Subject: [PATCH 12/64] chore(plans): add hot-reload-stale-indices plan
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
plans/hot-reload-stale-indices.md | 106 ++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 plans/hot-reload-stale-indices.md
diff --git a/plans/hot-reload-stale-indices.md b/plans/hot-reload-stale-indices.md
new file mode 100644
index 0000000..63d94ba
--- /dev/null
+++ b/plans/hot-reload-stale-indices.md
@@ -0,0 +1,106 @@
+---
+status: in-progress
+depends: []
+specs:
+ - specs/behaviors/storage.md
+ - specs/behaviors/legacy-id-mapping.md
+ - specs/behaviors/slug-handles.md
+issues: []
+---
+
+# Plan: hot reload leaves three secondary indices stale
+
+## Scope
+
+`swapInPlace` in `apps/api/src/store/memory/reload.ts` replaces the
+contents of every Map on `InMemoryState` by an explicit, hand-maintained
+list of `replaceMapContents` calls. Three indices were never added to that
+list: `projectIdByLegacyId`, `buzzIdBySlug`, and `slugHistory`. After the
+hot-reload webhook (`POST /api/_internal/reload-data`) those three still
+hold pre-reload contents.
+
+The user-visible consequence: the laddr importer mints fresh UUIDv7 ids on
+every run, so a re-import merged into `published` followed by a hot reload
+leaves `projectIdByLegacyId` pointing at project ids that no longer exist.
+Legacy `/projects?ID=` and `/project-updates?ProjectID=` redirects
+fall through to the SPA (404) until the pod restarts. Buzz-by-slug
+(`/project-buzz/`) and slug-history 301s go stale the same way.
+
+In scope:
+
+- Spec: make the hot-reload atomicity rule say *every* collection is
+ replaced, naming the three indices that were missed.
+- Fix `swapInPlace` so it cannot omit a field again.
+- Tests that (a) enumerate every collection on a freshly built state and
+ assert the swap replaced it, and (b) drive the real webhook through a
+ re-import scenario and assert the legacy and slug-history redirects
+ follow the new records.
+
+Out of scope: anything about the reconcile state machine, the FTS reload,
+or the push daemon. Those paths were not affected.
+
+## Implements
+
+- [behaviors/storage.md](../specs/behaviors/storage.md) — Hot reload →
+ Atomicity: every collection on the live state is replaced from the fresh
+ one, including legacy-id, buzz-by-slug, and slug-history indices.
+- [behaviors/legacy-id-mapping.md](../specs/behaviors/legacy-id-mapping.md)
+ — legacy redirects resolve against current records after a reload.
+- [behaviors/slug-handles.md](../specs/behaviors/slug-handles.md) —
+ slug-history redirects resolve against current records after a reload.
+
+## Approach
+
+1. **Spec first.** One sentence added to the Atomicity bullet of the
+ hot-reload section.
+2. **Enumerate, don't list.** Replace the hand-maintained list in
+ `swapInPlace` with a loop over `Object.keys(fresh)`. Every own property
+ of `InMemoryState` is a Map today; the loop asserts that at runtime and
+ throws a descriptive error if a future field is something else, so a
+ new non-Map field fails loudly in the test suite rather than being
+ silently skipped. Nested `Set` values inside index Maps are copied by
+ reference from `fresh`, which is correct — `fresh` is discarded after
+ the swap and nothing else holds those Sets.
+3. **Unit guard.** New `apps/api/tests/reload-swap.test.ts` builds two
+ `InMemoryState`s from different hand-crafted records (different ids,
+ legacy ids, slugs, slug-history entries), swaps, and for every own
+ property of the fresh state asserts `live[key]` deep-equals
+ `fresh[key]` while `live` keeps its object and Map identities. Also
+ checks that the three previously stale indices no longer resolve the
+ old values.
+4. **Integration guard.** Extend `apps/api/tests/internal-reload.test.ts`
+ with a re-import scenario: seed a project carrying `legacyId`, boot,
+ confirm the legacy redirect; advance the remote by deleting that
+ record and writing a replacement with a fresh id and slug plus a
+ slug-history record; fire the webhook; assert the legacy redirect,
+ the buzz redirect, and the slug-history redirect all point at the new
+ slug.
+
+## Validation
+
+- [ ] `specs/behaviors/storage.md` hot-reload Atomicity bullet names every
+ collection including legacy-id, buzz-by-slug, slug-history.
+- [ ] `swapInPlace` replaces every own property of `InMemoryState` without
+ an explicit per-field list.
+- [ ] Unit test enumerates every collection field of a fresh state and
+ asserts the swap replaced each one; fails on the pre-fix code.
+- [ ] Integration test: after re-import + webhook, `/projects?ID=`,
+ `/project-buzz/`, and old-slug URLs 301 to the new slug.
+- [ ] `npm run type-check && npm run lint && npm test` clean from repo root.
+
+## Risks / unknowns
+
+- **A future non-Map field on `InMemoryState`.** The enumerating swap
+ throws if it meets one. That is deliberate: the author of the new field
+ has to decide how it is swapped, and the unit test surfaces the
+ question immediately.
+- **Concurrent branch touching `apps/api/src/notify/*`, `plugins/services.ts`,
+ `env.ts`.** This plan does not touch those files.
+
+## Notes
+
+(Populated at closeout.)
+
+## Follow-ups
+
+(Populated at closeout.)
From 55c720f96962a1de4fb82503af65ae789b7a8f78 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 19:31:16 -0400
Subject: [PATCH 13/64] fix(api): rebuild every in-memory index on hot reload
swapInPlace named each Map on InMemoryState by hand and skipped three
secondary indices: projectIdByLegacyId, buzzIdBySlug, and slugHistory.
After POST /api/_internal/reload-data those three still described the
pre-reload state. Because the laddr importer mints fresh UUIDv7 ids on
every run, a re-import merged into `published` followed by a hot reload
left projectIdByLegacyId pointing at project ids that no longer existed,
so legacy /projects?ID= redirects fell through to the SPA until the
pod restarted; /project-buzz/ and slug-history 301s went stale the
same way.
Enumerate the fresh state's own properties instead of maintaining a
list, and throw if a property is ever not a Map so a future field is
handled deliberately rather than skipped again.
Guards against recurrence:
- reload-swap.test.ts builds two states with one record of every entity
type (different ids, same legacy ids), swaps, and asserts every own
property of the fresh state was replaced while Map identities are kept.
Fails on the old code for all three missing indices.
- internal-reload.test.ts gains a re-import scenario through the real
webhook: the project and buzz are replaced with fresh ids and slugs
plus a slug-history record, and the legacy-id, buzz-slug, and old-slug
redirects must all land on the new slug afterwards.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/api/src/store/memory/reload.ts | 55 +++---
apps/api/tests/internal-reload.test.ts | 192 +++++++++++++++++--
apps/api/tests/reload-swap.test.ts | 247 +++++++++++++++++++++++++
3 files changed, 444 insertions(+), 50 deletions(-)
create mode 100644 apps/api/tests/reload-swap.test.ts
diff --git a/apps/api/src/store/memory/reload.ts b/apps/api/src/store/memory/reload.ts
index 3e53ec3..bb1c430 100644
--- a/apps/api/src/store/memory/reload.ts
+++ b/apps/api/src/store/memory/reload.ts
@@ -85,42 +85,31 @@ export async function reloadInMemoryStateAndFts(
}
/**
- * Synchronously replace the contents of every Map on `live` with the
- * contents from `fresh`. Object identity of `live` is preserved.
+ * Synchronously replace the contents of every collection on `live` with
+ * the contents from `fresh`. Object identity of `live` — and of every Map
+ * hanging off it — is preserved.
+ *
+ * Enumerates `fresh`'s own properties rather than naming each field: a
+ * hand-maintained list silently skipped three secondary indices
+ * (`projectIdByLegacyId`, `buzzIdBySlug`, `slugHistory`) and left legacy
+ * and slug-history redirects pointing at ids that no longer existed after
+ * a re-import + hot reload. Every own property of `InMemoryState` is a Map
+ * today; if a future field is anything else this throws so the author has
+ * to decide how it's swapped, instead of it being skipped again. Per
+ * specs/behaviors/storage.md#hot-reload → Atomicity.
*
* Exported for testability — production code should call
* `reloadInMemoryStateAndFts`.
*/
export function swapInPlace(live: InMemoryState, fresh: InMemoryState): void {
- // Primary entity maps.
- replaceMapContents(live.projects, fresh.projects);
- replaceMapContents(live.people, fresh.people);
- replaceMapContents(live.tags, fresh.tags);
- replaceMapContents(live.tagAssignments, fresh.tagAssignments);
- replaceMapContents(live.projectMemberships, fresh.projectMemberships);
- replaceMapContents(live.projectUpdates, fresh.projectUpdates);
- replaceMapContents(live.projectBuzz, fresh.projectBuzz);
- replaceMapContents(live.blogPosts, fresh.blogPosts);
- replaceMapContents(live.helpWantedRoles, fresh.helpWantedRoles);
- replaceMapContents(live.helpWantedInterest, fresh.helpWantedInterest);
-
- // Secondary indices.
- replaceMapContents(live.projectSlugById, fresh.projectSlugById);
- replaceMapContents(live.projectIdBySlug, fresh.projectIdBySlug);
- replaceMapContents(live.personSlugById, fresh.personSlugById);
- replaceMapContents(live.personIdBySlug, fresh.personIdBySlug);
- replaceMapContents(live.tagIdByHandle, fresh.tagIdByHandle);
- replaceMapContents(live.membershipsByProject, fresh.membershipsByProject);
- replaceMapContents(live.membershipsByPerson, fresh.membershipsByPerson);
- replaceMapContents(live.updatesByProject, fresh.updatesByProject);
- replaceMapContents(live.updateByProjectAndNumber, fresh.updateByProjectAndNumber);
- replaceMapContents(live.buzzByProject, fresh.buzzByProject);
- replaceMapContents(live.buzzByProjectAndSlug, fresh.buzzByProjectAndSlug);
- replaceMapContents(live.blogPostIdBySlug, fresh.blogPostIdBySlug);
- replaceMapContents(live.blogPostIdByLegacyId, fresh.blogPostIdByLegacyId);
- replaceMapContents(live.helpWantedByProject, fresh.helpWantedByProject);
- replaceMapContents(live.tagAssignmentsByTaggable, fresh.tagAssignmentsByTaggable);
- replaceMapContents(live.tagAssignmentsByTag, fresh.tagAssignmentsByTag);
- replaceMapContents(live.interestByRoleAndPerson, fresh.interestByRoleAndPerson);
- replaceMapContents(live.interestByRole, fresh.interestByRole);
+ for (const key of Object.keys(fresh) as (keyof InMemoryState)[]) {
+ const target: unknown = live[key];
+ const source: unknown = fresh[key];
+ if (!(target instanceof Map) || !(source instanceof Map)) {
+ throw new Error(
+ `swapInPlace: InMemoryState.${key} is not a Map — extend swapInPlace to handle it`,
+ );
+ }
+ replaceMapContents(target, source);
+ }
}
diff --git a/apps/api/tests/internal-reload.test.ts b/apps/api/tests/internal-reload.test.ts
index fffd5c5..85e754d 100644
--- a/apps/api/tests/internal-reload.test.ts
+++ b/apps/api/tests/internal-reload.test.ts
@@ -15,6 +15,12 @@
* record introduced on the "remote" must be visible via a service
* call AFTER the reload completes (proves the in-memory state +
* FTS index actually got rebuilt against the new tree).
+ * - Re-import scenario: a project is replaced on the remote by a
+ * record with a fresh id and slug (what the laddr importer does on
+ * every run). After the reload the legacy `/projects?ID=` redirect,
+ * the `/project-buzz/` redirect, and the slug-history 301
+ * must all resolve against the NEW records — the secondary indices
+ * behind them were once skipped by the in-place swap.
*/
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
@@ -117,14 +123,14 @@ async function createRig(): Promise {
}
/**
- * Advance the bare remote by one commit on `main` via an ephemeral
- * clone. Used to put the local working tree behind so a hot reload
- * fast-forwards. The new commit introduces a fresh project record at
- * `projects/.toml`.
+ * Advance the bare remote by one commit on `main` via an ephemeral clone.
+ * `mutate` edits the clone's working tree and stages whatever it changed
+ * (paths are relative to the clone root). Returns the new remote HEAD.
*/
-async function advanceRemoteWithProject(
+async function advanceRemote(
rig: Rig,
- fields: { id: string; slug: string; title: string; summary?: string },
+ message: string,
+ mutate: (wt: string) => Promise,
): Promise {
const wt = `${rig.local}-advance-${Date.now()}-${Math.random()
.toString(36)
@@ -135,12 +141,31 @@ async function advanceRemoteWithProject(
await git(wt, 'config', 'commit.gpgsign', 'false');
await git(wt, 'config', 'core.hooksPath', '/dev/null');
- // Minimal Project TOML the gitsheets reader will accept + the Zod
- // schema will validate at load time. The schema allows a lot of
- // optional fields; we provide only the required ones plus a couple
- // for the assertion.
- const toml = [
+ await mutate(wt);
+ await git(wt, 'commit', '-m', message);
+ await git(wt, 'push', 'origin', 'main');
+ const head = await git(wt, 'rev-parse', 'HEAD');
+ await rm(wt, { recursive: true, force: true });
+ return head;
+}
+
+interface ProjectFields {
+ id: string;
+ slug: string;
+ title: string;
+ summary?: string;
+ legacyId?: number;
+}
+
+/**
+ * Minimal Project TOML the gitsheets reader will accept + the Zod schema
+ * will validate at load time. The schema allows a lot of optional fields;
+ * we provide only the required ones plus a couple for the assertions.
+ */
+function projectToml(fields: ProjectFields): string {
+ return [
`id = '${fields.id}'`,
+ ...(fields.legacyId !== undefined ? [`legacyId = ${fields.legacyId}`] : []),
`slug = '${fields.slug}'`,
`title = '${fields.title}'`,
...(fields.summary ? [`summary = '${fields.summary}'`] : []),
@@ -150,14 +175,66 @@ async function advanceRemoteWithProject(
`updatedAt = '2026-05-19T00:00:00Z'`,
'',
].join('\n');
+}
+
+async function writeProject(wt: string, fields: ProjectFields): Promise {
await exec('mkdir', ['-p', join(wt, 'projects')]);
- await writeFile(join(wt, 'projects', `${fields.slug}.toml`), toml);
+ await writeFile(join(wt, 'projects', `${fields.slug}.toml`), projectToml(fields));
await git(wt, 'add', `projects/${fields.slug}.toml`);
- await git(wt, 'commit', '-m', `seed: project ${fields.slug}`);
- await git(wt, 'push', 'origin', 'main');
- const head = await git(wt, 'rev-parse', 'HEAD');
- await rm(wt, { recursive: true, force: true });
- return head;
+}
+
+async function writeBuzz(
+ wt: string,
+ fields: { id: string; projectId: string; projectSlug: string; slug: string },
+): Promise {
+ const rel = `project-buzz/${fields.projectSlug}/${fields.slug}.toml`;
+ await exec('mkdir', ['-p', join(wt, 'project-buzz', fields.projectSlug)]);
+ await writeFile(
+ join(wt, rel),
+ [
+ `id = '${fields.id}'`,
+ `projectId = '${fields.projectId}'`,
+ `slug = '${fields.slug}'`,
+ `headline = 'Buzz ${fields.slug}'`,
+ `url = 'https://example.test/${fields.slug}'`,
+ `publishedAt = '2026-05-19T00:00:00Z'`,
+ `createdAt = '2026-05-19T00:00:00Z'`,
+ `updatedAt = '2026-05-19T00:00:00Z'`,
+ '',
+ ].join('\n'),
+ );
+ await git(wt, 'add', rel);
+}
+
+async function writeSlugHistory(
+ wt: string,
+ fields: { id: string; entityId: string; oldSlug: string; newSlug: string },
+): Promise {
+ const rel = `slug-history/project/${fields.oldSlug}.toml`;
+ await exec('mkdir', ['-p', join(wt, 'slug-history', 'project')]);
+ await writeFile(
+ join(wt, rel),
+ [
+ `id = '${fields.id}'`,
+ `entityType = 'project'`,
+ `oldSlug = '${fields.oldSlug}'`,
+ `newSlug = '${fields.newSlug}'`,
+ `entityId = '${fields.entityId}'`,
+ `changedAt = '2026-05-19T00:00:00Z'`,
+ `expiresAt = '2099-01-01T00:00:00Z'`,
+ '',
+ ].join('\n'),
+ );
+ await git(wt, 'add', rel);
+}
+
+/**
+ * Advance the remote by one commit that introduces a fresh project record
+ * at `projects/.toml`. Used to put the local clone behind so a hot
+ * reload fast-forwards.
+ */
+async function advanceRemoteWithProject(rig: Rig, fields: ProjectFields): Promise {
+ return advanceRemote(rig, `seed: project ${fields.slug}`, (wt) => writeProject(wt, fields));
}
// ---------------------------------------------------------------------------
@@ -390,4 +467,85 @@ describe('POST /api/_internal/reload-data — short-circuit + reconcile', () =>
const contents = await git(rig.local, 'show', 'HEAD:projects/lazyloader.toml');
expect(contents).toContain("slug = 'lazyloader'");
});
+
+ it('re-points legacy, buzz, and slug-history redirects after a re-import mints fresh ids', async () => {
+ // Seed the remote with a project carrying a laddr legacy id plus one
+ // buzz item, then bring the local clone up to date so the app boots
+ // in-sync with those records already indexed (production pods
+ // bare-clone fresh on every boot, so in-sync at boot is the norm).
+ const oldProjectId = '01951a3c-0000-7000-8000-000000000101';
+ await advanceRemote(rig, 'seed: alpha-v1 + buzz', async (wt) => {
+ await writeProject(wt, { id: oldProjectId, slug: 'alpha-v1', title: 'Alpha', legacyId: 42 });
+ await writeBuzz(wt, {
+ id: '01951a3c-0000-7000-8000-000000000103',
+ projectId: oldProjectId,
+ projectSlug: 'alpha-v1',
+ slug: 'alpha-launch',
+ });
+ });
+ await git(rig.local, 'fetch', 'origin', `${rig.branch}:${rig.branch}`);
+ app = await buildTestApp({ CFP_DATA_RELOAD_SECRET: VALID_SECRET });
+
+ const legacyBefore = await app.inject({ method: 'GET', url: '/projects?ID=42' });
+ expect(legacyBefore.statusCode).toBe(301);
+ expect(legacyBefore.headers.location).toBe('/projects/alpha-v1');
+
+ const buzzBefore = await app.inject({ method: 'GET', url: '/project-buzz/alpha-launch' });
+ expect(buzzBefore.statusCode).toBe(301);
+ expect(buzzBefore.headers.location).toBe('/projects/alpha-v1/buzz/alpha-launch');
+
+ // Re-import: the importer replaces the tree wholesale, minting fresh
+ // ids. Same legacy id, new project id + slug, new buzz id + slug, and
+ // a slug-history record so the old slug keeps resolving.
+ const newProjectId = '01951a3c-0000-7000-8000-000000000201';
+ const newRemoteHead = await advanceRemote(rig, 're-import: alpha-v2', async (wt) => {
+ await git(wt, 'rm', '-q', 'projects/alpha-v1.toml', 'project-buzz/alpha-v1/alpha-launch.toml');
+ await writeProject(wt, { id: newProjectId, slug: 'alpha-v2', title: 'Alpha', legacyId: 42 });
+ await writeBuzz(wt, {
+ id: '01951a3c-0000-7000-8000-000000000203',
+ projectId: newProjectId,
+ projectSlug: 'alpha-v2',
+ slug: 'alpha-relaunch',
+ });
+ await writeSlugHistory(wt, {
+ id: '01951a3c-0000-7000-8000-000000000206',
+ entityId: newProjectId,
+ oldSlug: 'alpha-v1',
+ newSlug: 'alpha-v2',
+ });
+ });
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/_internal/reload-data',
+ headers: { authorization: `Bearer ${VALID_SECRET}` },
+ payload: { branch: rig.branch, commitHash: newRemoteHead },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.json<{ data: { rebuilt: boolean } }>().data.rebuilt).toBe(true);
+
+ // Legacy id → the NEW project. Before the fix, projectIdByLegacyId
+ // still held the old id, projectSlugById no longer knew it, and the
+ // request fell through to the SPA.
+ const legacyAfter = await app.inject({ method: 'GET', url: '/projects?ID=42' });
+ expect(legacyAfter.statusCode).toBe(301);
+ expect(legacyAfter.headers.location).toBe('/projects/alpha-v2');
+
+ const updatesAfter = await app.inject({ method: 'GET', url: '/project-updates?ProjectID=42' });
+ expect(updatesAfter.statusCode).toBe(301);
+ expect(updatesAfter.headers.location).toBe('/projects/alpha-v2');
+
+ // Buzz slug → the NEW buzz under the NEW project slug; the retired
+ // buzz slug no longer redirects.
+ const buzzAfter = await app.inject({ method: 'GET', url: '/project-buzz/alpha-relaunch' });
+ expect(buzzAfter.statusCode).toBe(301);
+ expect(buzzAfter.headers.location).toBe('/projects/alpha-v2/buzz/alpha-relaunch');
+ const buzzRetired = await app.inject({ method: 'GET', url: '/project-buzz/alpha-launch' });
+ expect(buzzRetired.statusCode).not.toBe(301);
+
+ // Slug history → the old project URL 301s to the new slug.
+ const slugAfter = await app.inject({ method: 'GET', url: '/projects/alpha-v1' });
+ expect(slugAfter.statusCode).toBe(301);
+ expect(slugAfter.headers.location).toBe('/projects/alpha-v2');
+ });
});
diff --git a/apps/api/tests/reload-swap.test.ts b/apps/api/tests/reload-swap.test.ts
new file mode 100644
index 0000000..b018837
--- /dev/null
+++ b/apps/api/tests/reload-swap.test.ts
@@ -0,0 +1,247 @@
+/**
+ * Unit tests for `swapInPlace` — the in-place Map replacement behind the
+ * hot-reload webhook (specs/behaviors/storage.md#hot-reload → Atomicity).
+ *
+ * The regression this guards: `swapInPlace` used to name each field of
+ * `InMemoryState` by hand and skipped `projectIdByLegacyId`,
+ * `buzzIdBySlug`, and `slugHistory`. Because the laddr importer mints
+ * fresh ids every run, a re-import + hot reload left legacy redirects
+ * pointing at project ids that no longer existed. These tests enumerate
+ * every own property of a fresh state so a newly added collection can't
+ * be silently skipped again.
+ */
+import { describe, expect, it } from 'vitest';
+import type {
+ BlogPost,
+ HelpWantedInterestExpression,
+ HelpWantedRole,
+ Person,
+ Project,
+ ProjectBuzz,
+ ProjectMembership,
+ ProjectUpdate,
+ SlugHistory,
+ Tag,
+ TagAssignment,
+} from '@cfp/shared/schemas';
+
+import { swapInPlace } from '../src/store/memory/reload.js';
+import {
+ createEmptyState,
+ indexBlogPost,
+ indexHelpWantedInterest,
+ indexHelpWantedRole,
+ indexMembership,
+ indexPerson,
+ indexProject,
+ indexProjectBuzz,
+ indexProjectUpdate,
+ indexSlugHistory,
+ indexTag,
+ indexTagAssignment,
+ slugHistoryKey,
+ type InMemoryState,
+} from '../src/store/memory/state.js';
+
+const NOW = '2026-06-01T00:00:00Z';
+const FAR_FUTURE = '2099-01-01T00:00:00Z';
+
+function uuid(n: number): string {
+ return `01951a3c-0000-7000-8000-${String(n).padStart(12, '0')}`;
+}
+
+function makeProject(n: number, slug: string, legacyId: number): Project {
+ return {
+ id: uuid(n),
+ legacyId,
+ slug,
+ title: slug,
+ summary: null,
+ overview: null,
+ stage: 'prototyping',
+ maintainerId: null,
+ featured: false,
+ deletedAt: null,
+ createdAt: NOW,
+ updatedAt: NOW,
+ };
+}
+
+function makePerson(n: number, slug: string): Person {
+ return {
+ id: uuid(n),
+ slug,
+ fullName: slug,
+ accountLevel: 'user',
+ createdAt: NOW,
+ updatedAt: NOW,
+ } as Person;
+}
+
+function makeBuzz(n: number, projectId: string, slug: string): ProjectBuzz {
+ return {
+ id: uuid(n),
+ projectId,
+ slug,
+ headline: slug,
+ url: `https://example.test/${slug}`,
+ publishedAt: NOW,
+ createdAt: NOW,
+ updatedAt: NOW,
+ };
+}
+
+function makeTag(n: number, slug: string): Tag {
+ return { id: uuid(n), namespace: 'tech', slug, title: slug, createdAt: NOW, updatedAt: NOW };
+}
+
+function makeAssignment(n: number, tagId: string, projectId: string): TagAssignment {
+ return { id: uuid(n), tagId, taggableType: 'project', taggableId: projectId, createdAt: NOW };
+}
+
+/**
+ * The remaining entity types only need the fields their index helpers read
+ * (ids + foreign keys). Cast rather than spell out every schema field —
+ * this test is about index bookkeeping, not record validation.
+ */
+function makeMembership(n: number, projectId: string, personId: string): ProjectMembership {
+ return { id: uuid(n), projectId, personId, role: 'member', createdAt: NOW, updatedAt: NOW } as unknown as ProjectMembership;
+}
+
+function makeUpdate(n: number, projectId: string, number: number): ProjectUpdate {
+ return { id: uuid(n), projectId, number, createdAt: NOW, updatedAt: NOW } as unknown as ProjectUpdate;
+}
+
+function makeBlogPost(n: number, slug: string, legacyId: number): BlogPost {
+ return { id: uuid(n), slug, legacyId, createdAt: NOW, updatedAt: NOW } as unknown as BlogPost;
+}
+
+function makeRole(n: number, projectId: string): HelpWantedRole {
+ return { id: uuid(n), projectId, createdAt: NOW, updatedAt: NOW } as unknown as HelpWantedRole;
+}
+
+function makeInterest(n: number, roleId: string, personId: string): HelpWantedInterestExpression {
+ return { id: uuid(n), roleId, personId, createdAt: NOW } as unknown as HelpWantedInterestExpression;
+}
+
+function makeSlugHistory(n: number, entityId: string, oldSlug: string, newSlug: string): SlugHistory {
+ return {
+ id: uuid(n),
+ entityType: 'project',
+ entityId,
+ oldSlug,
+ newSlug,
+ changedAt: NOW,
+ expiresAt: FAR_FUTURE,
+ };
+}
+
+/**
+ * Build a state holding one record of every entity type, with ids drawn
+ * from `base + n`. Two calls with different bases model "before" and
+ * "after a re-import that minted fresh ids": every collection differs.
+ */
+function buildState(base: number, slugs: { project: string; buzz: string; oldSlug: string }): InMemoryState {
+ const state = createEmptyState();
+ const project = makeProject(base + 1, slugs.project, 42);
+ const person = makePerson(base + 2, 'jane');
+ const tag = makeTag(base + 4, 'flutter');
+ const role = makeRole(base + 9, project.id);
+
+ indexProject(state, project);
+ indexPerson(state, person);
+ indexProjectBuzz(state, makeBuzz(base + 3, project.id, slugs.buzz));
+ indexTag(state, tag);
+ indexTagAssignment(state, makeAssignment(base + 5, tag.id, project.id));
+ indexMembership(state, makeMembership(base + 6, project.id, person.id));
+ indexProjectUpdate(state, makeUpdate(base + 7, project.id, 1));
+ indexBlogPost(state, makeBlogPost(base + 8, `${slugs.project}-post`, 7));
+ indexHelpWantedRole(state, role);
+ indexHelpWantedInterest(state, makeInterest(base + 10, role.id, person.id));
+ indexSlugHistory(state, makeSlugHistory(base + 11, project.id, slugs.oldSlug, slugs.project));
+ return state;
+}
+
+/** "Before" state: ids in the 1xx range, project slug alpha-v1. */
+function buildLiveState(): InMemoryState {
+ return buildState(100, { project: 'alpha-v1', buzz: 'alpha-launch', oldSlug: 'alpha-v0' });
+}
+
+/**
+ * "After re-import" state: freshly minted ids (2xx range), renamed project
+ * slug, a different buzz slug, and a slug-history entry pointing at the new
+ * slug. Same legacy ids as the live state — that's the real-world shape.
+ */
+function buildFreshState(): InMemoryState {
+ return buildState(200, { project: 'alpha-v2', buzz: 'alpha-relaunch', oldSlug: 'alpha-v1' });
+}
+
+describe('swapInPlace', () => {
+ it('replaces every collection on the live state with the fresh contents', () => {
+ const live = buildLiveState();
+ const fresh = buildFreshState();
+ const keys = Object.keys(fresh) as (keyof InMemoryState)[];
+
+ // Sanity: the fixture must actually exercise every field, otherwise a
+ // skipped field would trivially "match".
+ expect(keys.length).toBeGreaterThan(0);
+ for (const key of keys) {
+ expect(fresh[key], `fresh.${key} is empty — extend the fixture`).not.toEqual(live[key]);
+ }
+
+ swapInPlace(live, fresh);
+
+ for (const key of keys) {
+ expect(live[key], `live.${key} was not replaced`).toEqual(fresh[key]);
+ }
+ // Also catch fields present on live but somehow absent on fresh.
+ expect(Object.keys(live).sort()).toEqual(keys.sort());
+ });
+
+ it('preserves the identity of the state object and of every Map', () => {
+ const live = buildLiveState();
+ const fresh = buildFreshState();
+ const before = new Map(
+ (Object.keys(live) as (keyof InMemoryState)[]).map((k) => [k, live[k]]),
+ );
+
+ swapInPlace(live, fresh);
+
+ for (const [key, map] of before) {
+ expect(live[key], `live.${key} Map identity changed`).toBe(map);
+ }
+ });
+
+ it('re-points the legacy-id, buzz-by-slug, and slug-history indices at the new records', () => {
+ const live = buildLiveState();
+ const fresh = buildFreshState();
+ const oldProjectId = uuid(101);
+ const newProjectId = uuid(201);
+
+ expect(live.projectIdByLegacyId.get(42)).toBe(oldProjectId);
+ expect(live.buzzIdBySlug.get('alpha-launch')).toBe(uuid(103));
+ expect(live.slugHistory.get(slugHistoryKey('project', 'alpha-v0'))?.newSlug).toBe('alpha-v1');
+
+ swapInPlace(live, fresh);
+
+ // Legacy redirect path: legacyId → projectId → slug must resolve
+ // end-to-end against the new records.
+ expect(live.projectIdByLegacyId.get(42)).toBe(newProjectId);
+ expect(live.projectSlugById.get(live.projectIdByLegacyId.get(42) as string)).toBe('alpha-v2');
+
+ expect(live.buzzIdBySlug.get('alpha-launch')).toBeUndefined();
+ expect(live.buzzIdBySlug.get('alpha-relaunch')).toBe(uuid(203));
+
+ expect(live.slugHistory.get(slugHistoryKey('project', 'alpha-v0'))).toBeUndefined();
+ expect(live.slugHistory.get(slugHistoryKey('project', 'alpha-v1'))?.newSlug).toBe('alpha-v2');
+ });
+
+ it('throws if a field on InMemoryState is not a Map instead of skipping it', () => {
+ const live = buildLiveState();
+ const fresh = buildFreshState();
+ (fresh as unknown as Record).someFutureIndex = new Set(['x']);
+ (live as unknown as Record).someFutureIndex = new Set();
+
+ expect(() => swapInPlace(live, fresh)).toThrow(/someFutureIndex/);
+ });
+});
From d7c5f9f094a5565e0bbd176164f7972bda3b193a Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 19:33:48 -0400
Subject: [PATCH 14/64] chore(plans): mark hot-reload-stale-indices done (PR
#159)
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
plans/hot-reload-stale-indices.md | 39 ++++++++++++++++++++++++-------
1 file changed, 31 insertions(+), 8 deletions(-)
diff --git a/plans/hot-reload-stale-indices.md b/plans/hot-reload-stale-indices.md
index 63d94ba..799eeeb 100644
--- a/plans/hot-reload-stale-indices.md
+++ b/plans/hot-reload-stale-indices.md
@@ -1,11 +1,12 @@
---
-status: in-progress
+status: done
depends: []
specs:
- specs/behaviors/storage.md
- specs/behaviors/legacy-id-mapping.md
- specs/behaviors/slug-handles.md
issues: []
+pr: 159
---
# Plan: hot reload leaves three secondary indices stale
@@ -78,15 +79,15 @@ or the push daemon. Those paths were not affected.
## Validation
-- [ ] `specs/behaviors/storage.md` hot-reload Atomicity bullet names every
+- [x] `specs/behaviors/storage.md` hot-reload Atomicity bullet names every
collection including legacy-id, buzz-by-slug, slug-history.
-- [ ] `swapInPlace` replaces every own property of `InMemoryState` without
+- [x] `swapInPlace` replaces every own property of `InMemoryState` without
an explicit per-field list.
-- [ ] Unit test enumerates every collection field of a fresh state and
+- [x] Unit test enumerates every collection field of a fresh state and
asserts the swap replaced each one; fails on the pre-fix code.
-- [ ] Integration test: after re-import + webhook, `/projects?ID=`,
+- [x] Integration test: after re-import + webhook, `/projects?ID=`,
`/project-buzz/`, and old-slug URLs 301 to the new slug.
-- [ ] `npm run type-check && npm run lint && npm test` clean from repo root.
+- [x] `npm run type-check && npm run lint && npm test` clean from repo root.
## Risks / unknowns
@@ -99,8 +100,30 @@ or the push daemon. Those paths were not affected.
## Notes
-(Populated at closeout.)
+- **Diagnosis confirmed as stated.** Diffing the Map-typed fields of
+ `InMemoryState` against the `replaceMapContents` calls showed exactly the
+ three missing: `projectIdByLegacyId`, `buzzIdBySlug`, `slugHistory`. All
+ three are plain Maps (slug-history values are `{ newSlug, expiresAt }`
+ objects, no nested Sets), so the same copy-by-reference swap is correct
+ for them. No Set-typed top-level fields exist.
+- **The unit test failed 3/4 on the old code** (identity test passes
+ either way); the webhook re-import test failed at the post-reload legacy
+ redirect (404 instead of 301). Both verified by temporarily restoring the
+ pre-fix `reload.ts`.
+- **Boot-order gap found along the way.** `store` opens the gitsheets
+ Sheet snapshots before `reconcile` fast-forwards, and `services` builds
+ the in-memory state from those stale snapshots. Only bites when the
+ local clone is behind at boot (dev, tests) — production pods clone fresh.
+ The re-import test works around it with an explicit
+ `git fetch origin main:main` before boot. Filed as #160.
+- **Web test flakes under load.** `ProjectEdit` and `ExpressInterestModal`
+ timed out once while `npm test` ran concurrently with type-check + lint;
+ both pass on their own and on a quiet full `npm test -w apps/web` run.
+ Unrelated to this change (no `apps/web` files touched).
## Follow-ups
-(Populated at closeout.)
+- Issue [#160](https://github.com/CodeForPhilly/codeforphilly-ng/issues/160)
+ — boot-time reconcile should re-open the store snapshot (or open the
+ store after reconcile) so a behind-at-boot clone doesn't build
+ in-memory state from the pre-fast-forward tree.
From 86b988cf924a16f6b32416dae20b3610e57a2cf6 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 19:50:11 -0400
Subject: [PATCH 15/64] docs(saml): spec entityID as SAML_ENTITY_ID, endpoints
on CFP_SITE_HOST
The live IdP metadata advertises entityID and SingleSignOnService
Locations on the Slack team host because the route derived both from
SLACK_TEAM_HOST. Spec now separates the three values by source: the
entity ID (metadata entityID + assertion Issuer) is a stable logical
identifier from a new optional SAML_ENTITY_ID env var, defaulting to
https://codeforphilly.org/api/saml/slack/metadata and deliberately
independent of CFP_SITE_HOST so the pre-/post-cutover host flip doesn't
invalidate the issuer Slack stored at setup; endpoint Locations follow
CFP_SITE_HOST; SLACK_TEAM_HOST keeps only its Slack-side roles.
Env tables in architecture.md, deploy.md, secrets.md and .env.example
gain the new var. Adds plans/saml-self-host.md (in-progress) to carry
the code change, closing the follow-up left open by plans/saml-idp.md.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
.env.example | 10 ++++
docs/operations/deploy.md | 4 +-
docs/operations/secrets.md | 5 ++
plans/saml-self-host.md | 110 +++++++++++++++++++++++++++++++++++++
specs/api/saml.md | 24 +++++++-
specs/architecture.md | 7 ++-
6 files changed, 153 insertions(+), 7 deletions(-)
create mode 100644 plans/saml-self-host.md
diff --git a/.env.example b/.env.example
index 240dcb5..39b3952 100644
--- a/.env.example
+++ b/.env.example
@@ -71,6 +71,16 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
# PEM-encoded certificate matching SAML_PRIVATE_KEY.
# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----
+# Stable IdP entity ID — also the on every assertion. Slack stores
+# this at setup, so it must NOT change when CFP_SITE_HOST flips at cutover.
+# Leave unset unless registering a separate IdP with a different workspace.
+# See specs/api/saml.md#idp-identity-and-hosts.
+# SAML_ENTITY_ID=https://codeforphilly.org/api/saml/slack/metadata
+
+# Slack workspace host. Drives the ACS URL, NameID NameQualifier, and the
+# /chat + /launch redirects. Never used for our own entity ID or endpoints.
+# SLACK_TEAM_HOST=codeforphilly.slack.com
+
# ---------------------------------------------------------------------------
# Static SPA serving (production only)
# ---------------------------------------------------------------------------
diff --git a/docs/operations/deploy.md b/docs/operations/deploy.md
index 9989b5a..4945c72 100644
--- a/docs/operations/deploy.md
+++ b/docs/operations/deploy.md
@@ -225,7 +225,8 @@ comments. Production pod gets these mounted:
| `CFP_DATA_BRANCH` | ConfigMap | e.g. `fixture` / `main` |
| `CFP_DATA_RELOAD_SECRET` | **Secret** | Shared bearer-token for the hot-reload webhook; when unset the `/api/_internal/reload-data` endpoint returns 503. See [runbook.md](runbook.md#hot-reload-webhook). |
| `CFP_WEB_DIST_PATH` | ConfigMap | `/app/apps/web/dist` |
-| `CFP_SITE_HOST` | ConfigMap | Public-facing host (`codeforphilly.org` base, `next-v2.codeforphilly.org` sandbox). Drives the markdown renderer's external-link transform — anchors with a different host get `target="_blank" rel="noopener nofollow"`. |
+| `CFP_SITE_HOST` | ConfigMap | Public-facing host (`codeforphilly.org` base, `next-v2.codeforphilly.org` sandbox). Drives the markdown renderer's external-link transform — anchors with a different host get `target="_blank" rel="noopener nofollow"` — and the SAML IdP metadata's `SingleSignOnService` endpoint URLs. |
+| `SAML_ENTITY_ID` | ConfigMap | Optional. Stable SAML IdP entity ID / assertion `Issuer` (default `https://codeforphilly.org/api/saml/slack/metadata`). Leave unset everywhere Slack should keep trusting the production IdP identity — it deliberately does **not** follow `CFP_SITE_HOST`, so flipping the host at cutover doesn't require editing Slack's SAML config. Only set it when standing up a separate IdP registration (e.g. a sandbox pointed at a test workspace). See [specs/api/saml.md](../../specs/api/saml.md#idp-identity-and-hosts). |
| `POSTMARK_SERVER_TOKEN` | **Secret** | Postmark server API token for outbound notifications. When unset, the email notifier falls back to a no-op LoggingNotifier — convenient for dev + tests but means no real emails go out. |
| `POSTMARK_MESSAGE_STREAM` | ConfigMap | Postmark message stream for outbound mail (default `outbound`). Must exist on the server the token belongs to. |
| `CFP_NOTIFICATION_FROM` | ConfigMap | RFC 5322 sender address for outbound notifications (default `"Code for Philly "`). Sender domain must be a verified Postmark sender signature (already true for `codeforphilly.org` via the legacy site) before flipping `POSTMARK_SERVER_TOKEN` on. |
@@ -237,6 +238,7 @@ comments. Production pod gets these mounted:
| `GITHUB_OAUTH_CLIENT_SECRET` | **Secret** | OAuth app client secret |
| `CFP_JWT_SIGNING_KEY` | **Secret** | HS256 key (`openssl rand -base64 64`) |
| `SAML_PRIVATE_KEY` / `SAML_CERTIFICATE` | **Secret** | Slack IdP cert chain |
+| `SLACK_TEAM_HOST` | ConfigMap | Slack workspace host (default `codeforphilly.slack.com`). ACS URL, NameID `NameQualifier`, `/chat` + `/launch` redirect target. Never our own IdP identity. |
| `GIT_SSH_COMMAND` | ConfigMap | Wires `ssh` to the mounted deploy key |
## Rollback
diff --git a/docs/operations/secrets.md b/docs/operations/secrets.md
index 2e86f0e..e236000 100644
--- a/docs/operations/secrets.md
+++ b/docs/operations/secrets.md
@@ -108,6 +108,11 @@ integration ([specs/api/saml.md](../../specs/api/saml.md)).
- **Rotation impact:** Slack stops trusting assertions until its IdP config
is updated with the new cert. **Do not rotate without coordinating with
the Slack workspace admin.**
+- **Not a secret, but paired:** `SAML_ENTITY_ID` (ConfigMap, optional) is
+ the IdP identity Slack stores alongside this cert. It defaults to
+ `https://codeforphilly.org/api/saml/slack/metadata` and must stay stable
+ across host changes — see [deploy.md](deploy.md#environment-variables-reference)
+ and [specs/api/saml.md](../../specs/api/saml.md#idp-identity-and-hosts).
- **Rotation procedure:**
1. Generate new key + cert.
2. Upload the *new cert* to Slack as a secondary signing cert.
diff --git a/plans/saml-self-host.md b/plans/saml-self-host.md
new file mode 100644
index 0000000..822dbbb
--- /dev/null
+++ b/plans/saml-self-host.md
@@ -0,0 +1,110 @@
+---
+status: in-progress
+depends: [saml-idp]
+specs:
+ - specs/api/saml.md
+ - specs/architecture.md
+issues: []
+---
+
+# Plan: SAML IdP identity and endpoint hosts
+
+## Scope
+
+Fix the SAML IdP so that the metadata `entityID`, the assertion `Issuer`, and
+the `SingleSignOnService` endpoint URLs are built from our own settings rather
+than from `SLACK_TEAM_HOST`. Today the live metadata at
+`https://next.codeforphilly.org/api/saml/slack/metadata` advertises
+`entityID="https://codeforphilly.slack.com/api/saml/slack/metadata"` and
+`Location="https://codeforphilly.slack.com/api/saml/slack/sso"` — both on
+Slack's host. This closes the "entityID host source" follow-up left open by
+[`saml-idp`](saml-idp.md).
+
+In scope:
+
+- A new optional `SAML_ENTITY_ID` env var (default
+ `https://codeforphilly.org/api/saml/slack/metadata`) that is the single
+ source for both the metadata `entityID` and every assertion ``.
+- SSO endpoint `Location`s built from `CFP_SITE_HOST`.
+- `SLACK_TEAM_HOST` retains exactly its Slack-side roles: ACS URL, NameID
+ `NameQualifier`, `/launch` redirect target.
+- Spec + operator docs + `.env.example` describing the three-way split.
+- Tests covering the default entity ID, the `CFP_SITE_HOST`-driven endpoint
+ URL, and the entity ID staying put when `CFP_SITE_HOST` changes.
+
+Out of scope: re-registering the IdP with Slack (operator action, not code);
+any change to NameID or the attribute set.
+
+## Implements
+
+- [api/saml.md](../specs/api/saml.md) — the new "IdP identity and hosts"
+ section: `entityID` = `SAML_ENTITY_ID`; `SingleSignOnService/@Location` =
+ `https:///api/saml/slack/sso`; `Issuer` = `entityID`.
+- [architecture.md](../specs/architecture.md) — env table rows for
+ `SAML_ENTITY_ID`, `SLACK_TEAM_HOST`, `CFP_SITE_HOST`.
+
+## Approach
+
+1. **Spec first.** Add the "IdP identity and hosts" section to
+ `specs/api/saml.md` stating the three sources and the stability rule for
+ the entity ID; add `SAML_ENTITY_ID` to the env tables in the spec,
+ `specs/architecture.md`, `docs/operations/deploy.md`,
+ `docs/operations/secrets.md`, and `.env.example`.
+2. **Env.** Add `SAML_ENTITY_ID` to `EnvSchema` (zod) and the mirrored JSON
+ schema in `apps/api/src/env.ts`, defaulting to
+ `https://codeforphilly.org/api/saml/slack/metadata`.
+3. **Route.** In `getSamlContext` (`apps/api/src/routes/saml.ts`) build the
+ `SamlIdpSettings` as:
+ - `entityId: cfg.SAML_ENTITY_ID`
+ - `ssoLoginPostUrl` / `ssoLoginRedirectUrl`:
+ `https://${cfg.CFP_SITE_HOST}/api/saml/slack/sso`
+ - `slackTeamHost: cfg.SLACK_TEAM_HOST` (unchanged)
+ Drop the `issuerHost` derivation and its misleading comment.
+4. **Config.** `apps/api/src/saml/config.ts` already threads
+ `settings.entityId` into both the samlify `IdentityProvider({ entityID })`
+ (→ metadata) and `SlackSamlEntities.entityId`, which the route passes as
+ `issuerEntityId` into `buildResponseSubstitutions` (→ `{Issuer}` on both
+ the Response and the Assertion). No change needed there beyond doc
+ comments; verify rather than assume.
+5. **Tests.** Update `apps/api/tests/saml.test.ts`: metadata `entityID`
+ equals the default `SAML_ENTITY_ID`; SSO `Location`s use `CFP_SITE_HOST`;
+ assertion `Issuer` (both Response and Assertion) equals the entity ID; a
+ second app booted with `CFP_SITE_HOST=next.example.org` gets `Location`
+ on that host while `entityID` stays the default; an explicit
+ `SAML_ENTITY_ID` override flows through to both metadata and `Issuer`.
+
+## Validation
+
+- [ ] `GET /api/saml/slack/metadata` `entityID` is
+ `https://codeforphilly.org/api/saml/slack/metadata` with no
+ `SAML_ENTITY_ID` set, regardless of `SLACK_TEAM_HOST` / `CFP_SITE_HOST`.
+- [ ] Both `SingleSignOnService/@Location` values are
+ `https:///api/saml/slack/sso`; with
+ `CFP_SITE_HOST=next.example.org` they use that host.
+- [ ] `` on the Response and the Assertion equal the metadata
+ `entityID`, including when `SAML_ENTITY_ID` is overridden.
+- [ ] ACS URL, form action, `NameQualifier`, and `/launch` redirect still use
+ `SLACK_TEAM_HOST` (existing tests keep passing).
+- [ ] `SLACK_TEAM_HOST` no longer appears in any IdP-side URL (grep the route).
+- [ ] `npm run type-check && npm run lint && npm test` clean.
+
+## Risks / unknowns
+
+- **Slack already has the wrong entity ID on file.** If the `next.`
+ deployment's metadata was uploaded to Slack, Slack stored
+ `https://codeforphilly.slack.com/api/saml/slack/metadata` as the IdP
+ issuer. After this ships, assertions carry the correct issuer and Slack
+ will reject them until its SAML config is re-synced from the metadata URL.
+ Operator coordination, noted in Follow-ups.
+- **Cutover host flip.** `Location` changes from `next.codeforphilly.org` to
+ `codeforphilly.org` at cutover; Slack's IdP config needs a metadata refresh
+ then too (endpoint URLs only — the entity ID is untouched, so the trust
+ relationship survives).
+
+## Notes
+
+(Populated at closeout.)
+
+## Follow-ups
+
+(Populated at closeout.)
diff --git a/specs/api/saml.md b/specs/api/saml.md
index d586994..f99b2f0 100644
--- a/specs/api/saml.md
+++ b/specs/api/saml.md
@@ -59,11 +59,28 @@ Content-Type: application/samlmetadata+xml; charset=utf-8
The metadata declares:
-- `entityID` — our IdP entity ID, e.g., `https://codeforphilly.org/api/saml/slack/metadata`
-- `SingleSignOnService` binding(s) — `urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST` (for SP-initiated) and `urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect`
+- `entityID` — our IdP entity ID: the value of `SAML_ENTITY_ID` (default `https://codeforphilly.org/api/saml/slack/metadata`). See [IdP identity and hosts](#idp-identity-and-hosts) — this is a stable logical identifier, not a reflection of whatever host is serving the request.
+- `SingleSignOnService` binding(s) — `urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST` (for SP-initiated) and `urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect`, both with `Location="https:///api/saml/slack/sso"`
- `X509Certificate` — the IdP cert from `SAML_CERTIFICATE`
- NameID formats supported: `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent`
+## IdP identity and hosts
+
+Three distinct hosts/identifiers appear in the IdP's output, and each comes from its own setting. They must never be conflated:
+
+| Value | Source | Where it appears |
+| ----- | ------ | ---------------- |
+| IdP entity ID | `SAML_ENTITY_ID` (default `https://codeforphilly.org/api/saml/slack/metadata`) | Metadata `entityID`; `` on every Response **and** every Assertion |
+| Our SSO endpoint URLs | `https:///api/saml/slack/...` | Metadata `SingleSignOnService/@Location` (both bindings) |
+| Slack workspace host | `SLACK_TEAM_HOST` (default `codeforphilly.slack.com`) | ACS URL (`https:///sso/saml`) — `Destination`, `Recipient`, and the auto-submit form action; NameID `NameQualifier`; the `/launch` and `/chat` redirect target |
+
+Rules:
+
+- **The entity ID is a stable logical identifier.** Slack stores it at setup time and matches every assertion's `Issuer` against it, so it must not change when the site moves hosts. In particular it does **not** follow `CFP_SITE_HOST`: production runs at `next.codeforphilly.org` before cutover and `codeforphilly.org` after, and the entity ID is `https://codeforphilly.org/api/saml/slack/metadata` throughout. It is a URI by convention only — nothing fetches it. Change `SAML_ENTITY_ID` only when deliberately re-registering the IdP with Slack.
+- **The metadata `entityID` and the assertion `Issuer` are the same value**, read from the same setting. A metadata document that advertises one entity ID while assertions carry another is a bug.
+- **Endpoint URLs follow the host actually serving the API.** `SingleSignOnService/@Location` is built from `CFP_SITE_HOST`, so the metadata always points Slack at a URL that resolves to this deployment. When `CFP_SITE_HOST` flips at cutover the metadata's endpoint URLs change and Slack's IdP config should be refreshed from the metadata URL — but the entity ID (and therefore the trust relationship) is untouched.
+- **Nothing IdP-side is ever built from `SLACK_TEAM_HOST` other than the Slack-side values in the table.** `SLACK_TEAM_HOST` is Slack's host; it never appears in our entity ID or our endpoint URLs.
+
## GET /api/saml/slack/launch
**IdP-initiated sign-in** — the member is on our site and wants to sign into Slack.
@@ -132,12 +149,13 @@ There's no v1 plan to vary this — keeping the hook just preserves the legacy e
## Cert + key rotation
-The cert + private key are env-injected:
+The cert + private key are env-injected, alongside the IdP identity:
| Env var | Purpose |
| ------- | ------- |
| `SAML_PRIVATE_KEY` | PEM-encoded RSA private key for signing assertions |
| `SAML_CERTIFICATE` | PEM-encoded X.509 cert (the public half) |
+| `SAML_ENTITY_ID` | Optional. The IdP entity ID / assertion `Issuer` (default `https://codeforphilly.org/api/saml/slack/metadata`). Not a secret. See [IdP identity and hosts](#idp-identity-and-hosts). |
Slack's admin panel holds the matching public cert. Rotation is a coordinated procedure (per the legacy `docs/operations/update-saml2-certificate.md`):
diff --git a/specs/architecture.md b/specs/architecture.md
index 010a0c8..1dbff64 100644
--- a/specs/architecture.md
+++ b/specs/architecture.md
@@ -177,11 +177,12 @@ Runtime configuration (sealed-secrets in our cluster):
| `GITHUB_OAUTH_CLIENT_ID` / `GITHUB_OAUTH_CLIENT_SECRET` | GitHub OAuth app credentials — see [api/auth.md](api/auth.md) |
| `CFP_JWT_SIGNING_KEY` | HS256 key for session JWTs |
| `SAML_PRIVATE_KEY` / `SAML_CERTIFICATE` | Slack SAML IdP cert chain — see [api/saml.md](api/saml.md) |
-| `SLACK_TEAM_HOST` | Slack workspace host (default `codeforphilly.slack.com`). Used by the `/chat` redirect ([api/chat](screens/chat.md)) and the SAML SP entity binding. |
+| `SAML_ENTITY_ID` | Optional. Stable SAML IdP entity ID / assertion `Issuer` (default `https://codeforphilly.org/api/saml/slack/metadata`). Deliberately independent of `CFP_SITE_HOST` so cutover doesn't change the identifier Slack has on file — see [api/saml.md#idp-identity-and-hosts](api/saml.md#idp-identity-and-hosts). |
+| `SLACK_TEAM_HOST` | Slack workspace host (default `codeforphilly.slack.com`). Used by the `/chat` redirect ([api/chat](screens/chat.md)) and the SAML SP entity binding (ACS URL, NameQualifier). Never used for our own IdP entity ID or endpoint URLs. |
| `POSTMARK_SERVER_TOKEN` | Optional. When set, mutates the notifier from the no-op `LoggingNotifier` to the live `EmailNotifier` (Postmark transport). |
| `POSTMARK_MESSAGE_STREAM` | Optional. Postmark message stream for outbound mail (default `outbound`). |
| `CFP_NOTIFICATION_FROM` | Required when `POSTMARK_SERVER_TOKEN` is set; the `From:` address on outbound mail. |
-| `CFP_SITE_HOST` | Public site host (e.g., `codeforphilly.org`) — used by notifiers to build canonical URLs in email bodies. |
+| `CFP_SITE_HOST` | Public site host (e.g., `codeforphilly.org`) — used by notifiers to build canonical URLs in email bodies, by the markdown renderer's external-link transform, and by the SAML IdP metadata's `SingleSignOnService` endpoint URLs ([api/saml.md](api/saml.md#idp-identity-and-hosts)). |
| `CFP_DATA_RELOAD_SECRET` | Bearer token gating `POST /api/_internal/reload-data` — the hot-reload webhook. Optional in dev; required in prod. |
On pod start the entrypoint:
@@ -213,7 +214,7 @@ The importer pulls only public fields. Private data (emails, password hashes, ne
Three levels, matching laddr's `Person.AccountLevel`:
| Level | Who | Can |
-|-------|-----|-----|
+| ------- | ----- | ----- |
| **Anonymous** | Not signed in | Browse public content; view profiles, projects, updates, buzz |
| **User** | Signed-in member | Update own profile; post project updates on projects they're a member of; post buzz to any project; create new projects (auto-becomes maintainer) |
| **Staff** | Trusted contributor | Edit any project; manage project members; promote help-wanted to highlighted; moderate content |
From 4536eaa64785f8f5c92e4a62f7c75c962e7d1676 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 20:02:52 -0400
Subject: [PATCH 16/64] fix(api): build SAML entityID and SSO endpoints from
our own settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
getSamlContext derived the IdP entity ID and both SingleSignOnService
Locations from SLACK_TEAM_HOST, so the live metadata advertised
https://codeforphilly.slack.com/api/saml/slack/... — Slack's host, not
ours. Per specs/api/saml.md#idp-identity-and-hosts:
- entityId (metadata entityID + assertion Issuer, one value flowing
through SlackSamlEntities.entityId → issuerEntityId) now comes from the
new SAML_ENTITY_ID env var, defaulting to
https://codeforphilly.org/api/saml/slack/metadata. It is a stable
logical identifier and intentionally does not follow CFP_SITE_HOST, so
the next.codeforphilly.org → codeforphilly.org flip at cutover leaves
the issuer Slack stored at setup untouched.
- ssoLoginPostUrl / ssoLoginRedirectUrl are built on CFP_SITE_HOST so
the metadata points Slack at the host actually serving the API.
- SLACK_TEAM_HOST keeps only its Slack-side roles (ACS URL, NameID
NameQualifier, launch redirect).
Tests assert the default entityID, the CFP_SITE_HOST-driven Locations,
Issuer == entityID on both Response and Assertion, that
CFP_SITE_HOST=next.example.org moves the Locations without moving the
entityID, and that an explicit SAML_ENTITY_ID flows to both.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/api/src/env.ts | 24 +++++++-
apps/api/src/routes/saml.ts | 19 +++---
apps/api/src/saml/config.ts | 16 +++--
apps/api/tests/saml.test.ts | 115 ++++++++++++++++++++++++++++++++++--
4 files changed, 155 insertions(+), 19 deletions(-)
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index c460c84..6c2908f 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -6,6 +6,12 @@
*/
import { z } from 'zod';
+/**
+ * Default SAML IdP entity ID. Stable across hosts — see the SAML_ENTITY_ID
+ * field below and specs/api/saml.md#idp-identity-and-hosts.
+ */
+export const SAML_ENTITY_ID_DEFAULT = 'https://codeforphilly.org/api/saml/slack/metadata';
+
export const EnvSchema = z.object({
/** TCP port the Fastify server listens on. */
PORT: z.coerce.number().default(3001),
@@ -49,8 +55,17 @@ export const EnvSchema = z.object({
/** SAML IdP certificate (PEM) for the Slack SAML integration. */
SAML_CERTIFICATE: z.string().optional(),
/**
- * Slack workspace host. Used as the SAML `NameQualifier` per
- * specs/api/saml.md and shared with the `/chat` redirect handler.
+ * SAML IdP entity ID — the metadata `entityID` and the `` on every
+ * assertion. A stable logical identifier Slack stores at setup time, so it
+ * deliberately does NOT follow CFP_SITE_HOST: the pre-cutover
+ * `next.codeforphilly.org` deploy and the post-cutover `codeforphilly.org`
+ * deploy present the same issuer. Per specs/api/saml.md#idp-identity-and-hosts.
+ */
+ SAML_ENTITY_ID: z.url().default(SAML_ENTITY_ID_DEFAULT),
+ /**
+ * Slack workspace host. Used for the SAML ACS URL and `NameQualifier` per
+ * specs/api/saml.md and shared with the `/chat` redirect handler. Never
+ * used for our own IdP entity ID or endpoint URLs.
*/
SLACK_TEAM_HOST: z.string().default('codeforphilly.slack.com'),
/**
@@ -64,7 +79,9 @@ export const EnvSchema = z.object({
* `next-v2.codeforphilly.org` in sandbox). Used by the server-side
* markdown renderer to distinguish internal from external links — anchors
* with a host different from this one get `target="_blank" rel="noopener
- * nofollow"`. Per specs/behaviors/markdown-rendering.md.
+ * nofollow"`. Per specs/behaviors/markdown-rendering.md. Also the host the
+ * SAML IdP metadata advertises for its SSO endpoint Locations (per
+ * specs/api/saml.md#idp-identity-and-hosts).
*/
CFP_SITE_HOST: z.string().default('codeforphilly.org'),
/**
@@ -121,6 +138,7 @@ export const envJsonSchema = {
CFP_JWT_SIGNING_KEY: { type: 'string', minLength: 1 },
SAML_PRIVATE_KEY: { type: 'string' },
SAML_CERTIFICATE: { type: 'string' },
+ SAML_ENTITY_ID: { type: 'string', default: SAML_ENTITY_ID_DEFAULT },
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
CFP_WEB_DIST_PATH: { type: 'string' },
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
diff --git a/apps/api/src/routes/saml.ts b/apps/api/src/routes/saml.ts
index d07c7d0..e565398 100644
--- a/apps/api/src/routes/saml.ts
+++ b/apps/api/src/routes/saml.ts
@@ -128,18 +128,21 @@ function getSamlContext(fastify: FastifyInstance): SamlContext {
throw new ApiValidationError('SAML IdP is not configured');
}
- const base = `https://${cfg.SLACK_TEAM_HOST}`.replace('https://', '');
- const issuerHost = base;
- // Fallback to the team host for the metadata entity ID if we can't see
- // the inbound request origin. Per spec the entityID is our own URL —
- // we'll prefer the request origin when building responses.
+ // Three distinct sources, per specs/api/saml.md#idp-identity-and-hosts:
+ // - entityId (metadata entityID + assertion Issuer) is the stable
+ // SAML_ENTITY_ID — it must NOT track the serving host, because Slack
+ // stored it at setup and the host flips at cutover;
+ // - the SSO endpoint Locations follow CFP_SITE_HOST so the metadata
+ // points Slack at whatever host this deployment answers on;
+ // - SLACK_TEAM_HOST is Slack's side only (ACS URL, NameQualifier).
+ const ssoUrl = `https://${cfg.CFP_SITE_HOST}/api/saml/slack/sso`;
const ctx: SamlContext = {
entities: buildSlackSamlEntities({
privateKey: cfg.SAML_PRIVATE_KEY,
certificate: cfg.SAML_CERTIFICATE,
- entityId: `https://${issuerHost}/api/saml/slack/metadata`,
- ssoLoginPostUrl: `https://${issuerHost}/api/saml/slack/sso`,
- ssoLoginRedirectUrl: `https://${issuerHost}/api/saml/slack/sso`,
+ entityId: cfg.SAML_ENTITY_ID,
+ ssoLoginPostUrl: ssoUrl,
+ ssoLoginRedirectUrl: ssoUrl,
slackTeamHost: cfg.SLACK_TEAM_HOST,
}),
};
diff --git a/apps/api/src/saml/config.ts b/apps/api/src/saml/config.ts
index caa67a7..7038188 100644
--- a/apps/api/src/saml/config.ts
+++ b/apps/api/src/saml/config.ts
@@ -36,13 +36,21 @@ export interface SamlIdpSettings {
readonly privateKey: string;
/** PEM-encoded X.509 certificate (the public half). */
readonly certificate: string;
- /** The IdP entity ID — also the metadata URL. */
+ /**
+ * The IdP entity ID — becomes the metadata `entityID` AND the ``
+ * on every Response/Assertion (via `SlackSamlEntities.entityId` →
+ * `issuerEntityId`). A stable logical identifier (`SAML_ENTITY_ID`), not
+ * necessarily a URL that resolves on the serving host.
+ */
readonly entityId: string;
- /** The IdP SSO POST binding location (the /launch endpoint). */
+ /** The IdP SSO POST binding location — `https:///api/saml/slack/sso`. */
readonly ssoLoginPostUrl: string;
- /** The IdP SSO Redirect binding location. */
+ /** The IdP SSO Redirect binding location — same URL as the POST binding. */
readonly ssoLoginRedirectUrl: string;
- /** Slack team host (e.g. `codeforphilly.slack.com`). */
+ /**
+ * Slack team host (e.g. `codeforphilly.slack.com`). Slack-side only: the
+ * ACS URL and the NameID `NameQualifier`. Never part of our own identity.
+ */
readonly slackTeamHost: string;
}
diff --git a/apps/api/tests/saml.test.ts b/apps/api/tests/saml.test.ts
index 1352ca2..c2ce141 100644
--- a/apps/api/tests/saml.test.ts
+++ b/apps/api/tests/saml.test.ts
@@ -26,6 +26,36 @@ import { getSamlTestKeyPair, type SamlTestKeyPair } from './helpers/saml-cert.js
const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!';
const SLACK_TEAM_HOST = 'codeforphilly.slack.com';
+/** Default SAML_ENTITY_ID per specs/api/saml.md#idp-identity-and-hosts. */
+const DEFAULT_ENTITY_ID = 'https://codeforphilly.org/api/saml/slack/metadata';
+/** Default CFP_SITE_HOST — the SSO endpoint Locations are built on it. */
+const DEFAULT_SITE_HOST = 'codeforphilly.org';
+
+const MD_NS = 'urn:oasis:names:tc:SAML:2.0:metadata';
+const ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion';
+
+function ssoLocations(metadataXml: string): { entityId: string | null; locations: string[] } {
+ const doc = new DOMParser().parseFromString(metadataXml, 'application/xml');
+ const root = doc.documentElement;
+ const locations = Array.from(root?.getElementsByTagNameNS(MD_NS, 'SingleSignOnService') ?? [])
+ .map((el) => el.getAttribute('Location'))
+ .filter((v): v is string => typeof v === 'string');
+ return { entityId: root?.getAttribute('entityID') ?? null, locations };
+}
+
+/** Every `` text in a decoded SAMLResponse (Response + Assertion). */
+function issuers(responseXml: string): string[] {
+ const doc = new DOMParser().parseFromString(responseXml, 'application/xml');
+ return Array.from(doc.documentElement?.getElementsByTagNameNS(ASSERTION_NS, 'Issuer') ?? []).map(
+ (el) => el.textContent ?? '',
+ );
+}
+
+function decodeSamlResponse(html: string): string {
+ const match = /name="SAMLResponse" value="([^"]+)"/.exec(html);
+ expect(match).not.toBeNull();
+ return Buffer.from(match![1]!, 'base64').toString('utf8');
+}
async function seedPerson(
repoDir: string,
@@ -129,10 +159,17 @@ describe('SAML IdP — Slack', () => {
const root = doc.documentElement;
expect(root?.localName).toBe('EntityDescriptor');
- // entityID present
- expect(root?.getAttribute('entityID')).toBe(
- `https://${SLACK_TEAM_HOST}/api/saml/slack/metadata`,
- );
+ // entityID is the stable SAML_ENTITY_ID default — NOT built on
+ // SLACK_TEAM_HOST (Slack's host) and NOT on the serving host.
+ expect(root?.getAttribute('entityID')).toBe(DEFAULT_ENTITY_ID);
+
+ // Both SSO bindings point at our own site host.
+ const { locations } = ssoLocations(res.body);
+ expect(locations).toHaveLength(2);
+ for (const loc of locations) {
+ expect(loc).toBe(`https://${DEFAULT_SITE_HOST}/api/saml/slack/sso`);
+ }
+ expect(res.body).not.toContain(`https://${SLACK_TEAM_HOST}/api/saml`);
// IDPSSODescriptor + at least one SingleSignOnService and an X509Certificate.
const idpDescriptors = root?.getElementsByTagNameNS(
@@ -190,6 +227,10 @@ describe('SAML IdP — Slack', () => {
const root = doc.documentElement;
expect(root?.localName).toBe('Response');
+ // Issuer on both the Response and the Assertion is the entity ID — the
+ // same value the metadata advertises as entityID.
+ expect(issuers(xml)).toEqual([DEFAULT_ENTITY_ID, DEFAULT_ENTITY_ID]);
+
// NameID is the slackSamlNameId, format persistent
const nameIdEl = root?.getElementsByTagNameNS(
'urn:oasis:names:tc:SAML:2.0:assertion',
@@ -319,6 +360,72 @@ describe('SAML IdP — Slack', () => {
});
});
+describe('SAML IdP — entity ID vs. site host', () => {
+ let dataRepo: { path: string; cleanup: () => Promise };
+ let privateStore: { path: string; cleanup: () => Promise };
+ let keyPair: SamlTestKeyPair;
+ const personId = '01951a3c-0000-7000-8000-000000000002';
+ const slug = 'sam';
+
+ beforeAll(async () => {
+ keyPair = await getSamlTestKeyPair();
+ dataRepo = await createFullDataRepo();
+ privateStore = await createPrivateStorageDir();
+ await seedPerson(dataRepo.path, { id: personId, slug, slackSamlNameId: slug });
+ await seedPrivateProfile(privateStore.path, { personId, email: 'sam@example.com' });
+ });
+
+ afterAll(async () => {
+ await dataRepo.cleanup();
+ await privateStore.cleanup();
+ });
+
+ it('CFP_SITE_HOST moves the SSO Locations but leaves entityID alone', async () => {
+ const app = await buildTestApp(dataRepo.path, privateStore.path, keyPair, {
+ CFP_SITE_HOST: 'next.example.org',
+ });
+ try {
+ const res = await app.inject({ method: 'GET', url: '/api/saml/slack/metadata' });
+ expect(res.statusCode).toBe(200);
+ const { entityId, locations } = ssoLocations(res.body);
+ expect(locations).toHaveLength(2);
+ for (const loc of locations) {
+ expect(loc).toBe('https://next.example.org/api/saml/slack/sso');
+ }
+ // The pre-cutover host does not leak into the identifier Slack stores.
+ expect(entityId).toBe(DEFAULT_ENTITY_ID);
+ } finally {
+ await app.close();
+ }
+ });
+
+ it('SAML_ENTITY_ID overrides both the metadata entityID and the assertion Issuer', async () => {
+ const entityId = 'https://idp.example.org/saml/slack';
+ const app = await buildTestApp(dataRepo.path, privateStore.path, keyPair, {
+ SAML_ENTITY_ID: entityId,
+ CFP_SITE_HOST: 'next.example.org',
+ });
+ try {
+ const meta = await app.inject({ method: 'GET', url: '/api/saml/slack/metadata' });
+ expect(meta.statusCode).toBe(200);
+ expect(ssoLocations(meta.body).entityId).toBe(entityId);
+
+ const { accessToken } = await mintSessionFor(personId, 'user', JWT_KEY);
+ const launch = await app.inject({
+ method: 'GET',
+ url: '/api/saml/slack/launch',
+ cookies: { cfp_session: accessToken },
+ });
+ expect(launch.statusCode).toBe(200);
+ expect(issuers(decodeSamlResponse(launch.body))).toEqual([entityId, entityId]);
+ // Slack-side values still come from SLACK_TEAM_HOST.
+ expect(launch.body).toContain(`action="https://${SLACK_TEAM_HOST}/sso/saml"`);
+ } finally {
+ await app.close();
+ }
+ });
+});
+
describe('SAML IdP — without configured cert/key', () => {
let dataRepo: { path: string; cleanup: () => Promise };
let privateStore: { path: string; cleanup: () => Promise };
From b8a3fe1e444041d9317b2761016a10b8fd3f674d Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 20:03:23 -0400
Subject: [PATCH 17/64] chore(plans): mark saml-self-host done (PR #161)
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
plans/saml-self-host.md | 41 ++++++++++++++++++++++++++++++++---------
1 file changed, 32 insertions(+), 9 deletions(-)
diff --git a/plans/saml-self-host.md b/plans/saml-self-host.md
index 822dbbb..bfb804b 100644
--- a/plans/saml-self-host.md
+++ b/plans/saml-self-host.md
@@ -1,10 +1,11 @@
---
-status: in-progress
+status: done
depends: [saml-idp]
specs:
- specs/api/saml.md
- specs/architecture.md
issues: []
+pr: 161
---
# Plan: SAML IdP identity and endpoint hosts
@@ -75,18 +76,18 @@ any change to NameID or the attribute set.
## Validation
-- [ ] `GET /api/saml/slack/metadata` `entityID` is
+- [x] `GET /api/saml/slack/metadata` `entityID` is
`https://codeforphilly.org/api/saml/slack/metadata` with no
`SAML_ENTITY_ID` set, regardless of `SLACK_TEAM_HOST` / `CFP_SITE_HOST`.
-- [ ] Both `SingleSignOnService/@Location` values are
+- [x] Both `SingleSignOnService/@Location` values are
`https:///api/saml/slack/sso`; with
`CFP_SITE_HOST=next.example.org` they use that host.
-- [ ] `` on the Response and the Assertion equal the metadata
+- [x] `` on the Response and the Assertion equal the metadata
`entityID`, including when `SAML_ENTITY_ID` is overridden.
-- [ ] ACS URL, form action, `NameQualifier`, and `/launch` redirect still use
+- [x] ACS URL, form action, `NameQualifier`, and `/launch` redirect still use
`SLACK_TEAM_HOST` (existing tests keep passing).
-- [ ] `SLACK_TEAM_HOST` no longer appears in any IdP-side URL (grep the route).
-- [ ] `npm run type-check && npm run lint && npm test` clean.
+- [x] `SLACK_TEAM_HOST` no longer appears in any IdP-side URL (grep the route).
+- [x] `npm run type-check && npm run lint && npm test` clean.
## Risks / unknowns
@@ -103,8 +104,30 @@ any change to NameID or the attribute set.
## Notes
-(Populated at closeout.)
+- **`config.ts` needed no logic change.** `SamlIdpSettings.entityId` already
+ fed both samlify's `IdentityProvider({ entityID })` (→ metadata) and
+ `SlackSamlEntities.entityId`, which the route passes as `issuerEntityId`
+ into `buildResponseSubstitutions` (→ `{Issuer}` on Response + Assertion).
+ The bug was entirely in `getSamlContext`'s choice of host. Only doc
+ comments changed there.
+- **Entity ID is a URI by convention, not a fetchable URL.** Nothing (Slack
+ included) dereferences it, which is why it can stay on `codeforphilly.org`
+ while the deployment answers on `next.codeforphilly.org`. `SAML_ENTITY_ID`
+ is validated as a URL (`z.url()`) only to catch typos.
+- **`SLACK_TEAM_HOST` was never in the deploy env table** — it rode on its
+ default. Added a row so operators see it next to `SAML_ENTITY_ID` and
+ don't confuse the two hosts again.
+- **Web-suite timeout flake.** `apps/web/tests/ProjectEdit.test.tsx` hit its
+ 5s limit (5252ms) once under full-suite load and passed in isolation.
+ Unrelated to this change; noted in case it recurs.
## Follow-ups
-(Populated at closeout.)
+- Tracked as: operator action before deploying to `next.codeforphilly.org` —
+ if Slack's SAML config was seeded from the old metadata it holds the wrong
+ issuer (`https://codeforphilly.slack.com/...`) and will reject assertions
+ until re-synced from the metadata URL. Same metadata refresh (endpoint
+ URLs only) is needed again at cutover when `CFP_SITE_HOST` flips; see
+ `docs/operations/cutover.md` owners.
+- Tracked as: boot-time `slackSamlNameId` invariant scan — carried forward
+ unchanged from [`saml-idp`](saml-idp.md); not touched here.
From dd34602b0ea0e11cdc65de592983a51ba9621102 Mon Sep 17 00:00:00 2001
From: Heyoub
Date: Mon, 24 Aug 2026 11:47:14 -0400
Subject: [PATCH 18/64] chore(plans): add site-check-153 plan
Issue #153 walks the live site on desktop and mobile and collects five
findings. Four are shippable together because they all land in the app
shell or in one screen's outbound links; the fifth (replace the Home hero
CTA with a mailing-list invite) has no mechanism to build against, so the
plan records the block up front rather than inventing one.
Co-Authored-By: Claude Fable 5
---
plans/site-check-153.md | 151 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 151 insertions(+)
create mode 100644 plans/site-check-153.md
diff --git a/plans/site-check-153.md b/plans/site-check-153.md
new file mode 100644
index 0000000..d288c9a
--- /dev/null
+++ b/plans/site-check-153.md
@@ -0,0 +1,151 @@
+---
+status: in-progress
+depends: []
+specs:
+ - specs/behaviors/app-shell.md
+ - specs/screens/volunteer.md
+issues: [153]
+---
+
+# Plan: site check — header order, mobile sheet padding, dead outbound links
+
+## Scope
+
+Issue [#153](https://github.com/CodeForPhilly/codeforphilly-ng/issues/153)
+("Site check for desktop & mobile") collects a walkthrough of the live site on
+both breakpoints. Four of its items are shippable now; one is blocked (see
+Follow-ups).
+
+What ships:
+
+- **Desktop header reorder** (spec-governed). The Volunteer CTA leaves the
+ content nav and becomes the rightmost element of the header, after the auth
+ control; About joins the left cluster's text links; a GitHub icon link is
+ added to the right cluster.
+- **Mobile sheet padding + accessible name.** The sheet's nav and search sat
+ flush against the panel edge. Fixed with the intended shadcn structure
+ (`SheetHeader` + `SheetTitle`) plus explicit horizontal padding — which also
+ gives the underlying Radix dialog the accessible name it was missing.
+- **Header ARIA cleanups.** Three defects surfaced by an accessibility pass over
+ the header, done here because this plan rewrites the same file.
+- **Dead outbound links** (spec-governed). The whole `codeforphilly.gitbook.io`
+ space returns 404 "Content owner not found"; `Volunteer.tsx`'s two remaining
+ GitBook links are repointed at live equivalents. Same class of defect as
+ [`home-start-project-cta`](home-start-project-cta.md) (PR #128), which fixed
+ the Home screen's copy of the same dead URL.
+- **Footer repo URL.** The "view this site on GitHub" link still pointed at
+ `codeforphilly-rewrite`; the repo is `codeforphilly-ng` and the old URL only
+ resolves through GitHub's rename redirect.
+
+Explicitly out of scope:
+
+- **Replacing the Home hero's Volunteer CTA with a mailing-list invite** (also
+ recommended by #153) — blocked, see Follow-ups. `Home.tsx` is untouched.
+- Any other visual restyle of the header. The Volunteer button keeps its
+ existing green treatment; only its position changes.
+
+## Implements
+
+- [app-shell.md](../specs/behaviors/app-shell.md) — "Center / right at ≥ md"
+ split into a left content cluster and a right utility cluster, with the new
+ item order; "Auth controls" repositioned second-from-right; GitHub link added
+ to the right cluster and to the mobile sheet; the sheet's accessible name
+ added under Accessibility.
+- [volunteer.md](../specs/screens/volunteer.md) — "Show up to meetups" card
+ links to the Meetup group; "Start your own project" band links to the
+ `CodeForPhilly/partnerships` first-steps guide. Both replace dead GitBook
+ URLs.
+
+## Approach
+
+### 1. Spec changes first (specops — source of truth leads)
+
+`specs/behaviors/app-shell.md` and `specs/screens/volunteer.md` both prescribed
+the current (wrong) state, so they lead. Header spacing is deliberately *not*
+specced — [specs/README.md:49](../specs/README.md) puts spacing outside spec
+scope — so the mobile-sheet padding fix carries no spec change.
+
+### 2. `apps/web/src/components/AppHeader.tsx`
+
+- Left `
-
+
When we meet →
diff --git a/apps/web/tests/AppFooter.test.tsx b/apps/web/tests/AppFooter.test.tsx
index 6b0eb49..8924baf 100644
--- a/apps/web/tests/AppFooter.test.tsx
+++ b/apps/web/tests/AppFooter.test.tsx
@@ -17,7 +17,7 @@ describe('AppFooter', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
- 'https://github.com/CodeForPhilly/codeforphilly-rewrite',
+ 'https://github.com/CodeForPhilly/codeforphilly-ng',
);
});
diff --git a/apps/web/tests/Volunteer.test.tsx b/apps/web/tests/Volunteer.test.tsx
new file mode 100644
index 0000000..3a45b0c
--- /dev/null
+++ b/apps/web/tests/Volunteer.test.tsx
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderScreen, mockPaginated } from './test-utils.js';
+import { Volunteer } from '../src/screens/Volunteer.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+const MEETUP_URL = 'https://www.meetup.com/Code-for-Philly/';
+const START_PROJECT_URL =
+ 'https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md';
+
+describe('Volunteer', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ if (input.startsWith('/api/projects')) {
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([], { totalItems: 268 })), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify(mockPaginated([])), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function renderVolunteer() {
+ return renderScreen(
+
+
+ ,
+ );
+ }
+
+ it('renders the hero headline', () => {
+ renderVolunteer();
+ expect(
+ screen.getByRole('heading', {
+ name: /volunteer with code for philly/i,
+ level: 1,
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('points "When we meet →" at the live Meetup group, not the dead GitBook page', () => {
+ renderVolunteer();
+ const link = screen.getByRole('link', { name: /when we meet/i });
+ expect(link).toHaveAttribute('href', MEETUP_URL);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('points "Read the guide →" at the partnerships repo, not the dead GitBook page', () => {
+ renderVolunteer();
+ const link = screen.getByRole('link', { name: /read the guide/i });
+ expect(link).toHaveAttribute('href', START_PROJECT_URL);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('has no codeforphilly.gitbook.io links anywhere on the screen', async () => {
+ const { container } = renderVolunteer();
+
+ // Wait for the live project count so the fully-settled DOM is asserted on.
+ await waitFor(() => {
+ expect(screen.getByText(/browse 268 active projects/i)).toBeInTheDocument();
+ });
+
+ const hrefs = Array.from(container.querySelectorAll('a')).map(
+ (a) => a.getAttribute('href') ?? '',
+ );
+ expect(hrefs.filter((h) => h.includes('gitbook.io'))).toHaveLength(0);
+ expect(container.innerHTML).not.toContain('codeforphilly.gitbook.io');
+ });
+});
From 3ca3d959c7d67e6e7ebf75c8e57c415ab3d92546 Mon Sep 17 00:00:00 2001
From: Heyoub
Date: Mon, 24 Aug 2026 12:04:30 -0400
Subject: [PATCH 22/64] fix(web): let the mobile sheet nav scroll when it
overflows
The sheet is a full-height flex column with no scroll container, so a nav
list taller than the viewport was simply unreachable below the fold. That
was already latent; adding the GitHub row makes it one row likelier on
short phones. min-h-0 lets the flex child shrink at all, and overflow-y-auto
gives it somewhere to put the excess.
Co-Authored-By: Claude Fable 5
---
apps/web/src/components/AppHeader.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index dd414de..7991907 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -288,9 +288,11 @@ export function AppHeader() {
Menu
+ {/* min-h-0 + overflow-y-auto so the list stays reachable on
+ short viewports instead of overflowing the panel. */}
Date: Mon, 24 Aug 2026 12:24:58 -0400
Subject: [PATCH 23/64] chore(plans): record site-check-153 validation results
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ticks the criteria verified during implementation and, deliberately,
leaves two unticked rather than rewriting them to match what was
achievable: the browser pass belongs to whoever does UI QA, and the
all-workspaces test gate cannot close on this Windows dev box.
Notes record why. apps/api fails ten tests here on a tree whose API code
is byte-identical to develop, because the fixtures assume POSIX —
store.test.ts injects a write failure via /dev/null/impossible-path,
which Windows will happily create, so the expected rejection never comes.
Reproduces with the files run alone, so it is not runner contention.
Filed as a follow-up rather than fixed: cross-platform fixtures are their
own scope, and silently ticking a gate that did not run is worse than
leaving the box open.
Also flags HomeStub.tsx, which carries the same stale repo URL the footer
had but is imported by nothing.
Co-Authored-By: Claude Fable 5
---
plans/site-check-153.md | 53 +++++++++++++++++++++++++++++++----------
1 file changed, 41 insertions(+), 12 deletions(-)
diff --git a/plans/site-check-153.md b/plans/site-check-153.md
index d288c9a..5a18097 100644
--- a/plans/site-check-153.md
+++ b/plans/site-check-153.md
@@ -106,17 +106,20 @@ One-line repo URL swap to `codeforphilly-ng`.
## Validation
-- [ ] Specs updated before code: app-shell header clusters + volunteer link targets.
-- [ ] Desktop header order is Projects · Help Wanted · Members · About ▾ … GitHub · Search · Sign in · Volunteer, with Volunteer rightmost and still green.
-- [ ] No `ml-1` spacing hacks remain among the header nav's children.
-- [ ] GitHub link is icon-only, labelled "Code for Philly on GitHub", and opens `https://github.com/CodeForPhilly` in a new tab with `rel="noopener noreferrer"`.
-- [ ] Mobile sheet has a "Menu" title, horizontal padding on nav + search, and no `pt-8`; the title does not collide with the close button.
-- [ ] The sheet dialog exposes an accessible name; Radix still supplies `aria-expanded` on the trigger.
-- [ ] Loading skeleton uses `aria-hidden`; About trigger's accessible name is its visible text; account-menu label retained.
-- [ ] Every mobile sheet item closes the sheet on click, including Contact.
-- [ ] No `codeforphilly.gitbook.io` URL remains in `apps/web/src`.
-- [ ] Footer "view this site on GitHub" points at `codeforphilly-ng`.
-- [ ] `npm run -w packages/shared build && npm run type-check && npm run lint && npm test` clean.
+- [x] Specs updated before code: app-shell header clusters + volunteer link targets.
+- [x] Desktop header order is Projects · Help Wanted · Members · About ▾ … GitHub · Search · Sign in · Volunteer, with Volunteer rightmost and still green.
+- [x] No `ml-1` spacing hacks remain among the header nav's children.
+- [x] GitHub link is icon-only, labelled "Code for Philly on GitHub", and opens `https://github.com/CodeForPhilly` in a new tab with `rel="noopener noreferrer"`.
+- [x] Mobile sheet has a "Menu" title, horizontal padding on nav + search, and no `pt-8`; the title does not collide with the close button.
+- [x] The sheet dialog exposes an accessible name; Radix still supplies `aria-expanded` on the trigger.
+- [x] Loading skeleton uses `aria-hidden`; About trigger's accessible name is its visible text; account-menu label retained.
+- [x] Every mobile sheet item closes the sheet on click, including Contact.
+- [x] No `codeforphilly.gitbook.io` URL remains in `apps/web/src`.
+- [x] Footer "view this site on GitHub" points at `codeforphilly-ng`.
+- [x] Both replacement URLs return 200 and carry the expected content.
+- [x] `npm run -w packages/shared build`, `npm run type-check`, and `npm run lint` clean.
+- [x] `npm test` clean for the workspaces this plan touches: web 96/96, shared 75/75.
+- [ ] `npm test` clean for **all** workspaces — `apps/api` cannot pass on the Windows dev box used here (see Notes); needs a Linux run or CI to close.
- [ ] Browser test: desktop header order + mobile sheet padding at < md, both breakpoints.
## Risks
@@ -131,7 +134,20 @@ One-line repo URL swap to `codeforphilly-ng`.
## Notes
-(To be populated at closeout.)
+(To be populated at closeout. Recorded during implementation:)
+
+- **`apps/api` tests do not pass on Windows, independent of this plan.** Ten
+ failures across `scrub-data.test.ts` (4), `internal-reload.test.ts` (4), and
+ `store.test.ts` (2), on a tree where `git diff develop..HEAD -- apps/api
+ packages/` is empty — this branch touches no API code. The mechanism is
+ POSIX-isms in the test fixtures: `store.test.ts` injects a write failure by
+ pointing the private store at `/dev/null/impossible-path` and asserting the
+ transaction rejects, but on Windows that is an ordinary creatable directory,
+ so the write succeeds and the expected throw never happens. They reproduce
+ with the files run alone, so it is not test-runner contention. CI runs the
+ same gate on Linux, where the fixture behaves as intended. Worth a
+ cross-platform fixture cleanup if Windows dev boxes are to be supported;
+ filed under Follow-ups.
## Follow-ups
@@ -149,3 +165,16 @@ One-line repo URL swap to `codeforphilly-ng`.
worker, unsubscribe tokens). Building an anonymous-capture CTA ahead of that
spec would invent unspecified behavior. `Home.tsx` is deliberately untouched
here; the CTA swap should follow the newsletter spec work, not precede it.
+
+- **Tracked as: dead file, not fixed here — `apps/web/src/pages/HomeStub.tsx`.**
+ It carries the same stale `codeforphilly-rewrite` URL the footer had, but
+ nothing imports or routes it (`App.tsx` imports only `LoginPlaceholder` from
+ `src/pages/`; every live screen lives in `src/screens/`). Left alone because
+ the right fix is deleting the file, not patching a URL nobody renders — and
+ that deletion wants its own scope. Flagging so a future grep for the old repo
+ name doesn't read as an unfixed live link.
+
+- **Issue — make the `apps/api` test fixtures cross-platform.** The `/dev/null`
+ failure-injection idiom (and whatever the other seven failures share) makes
+ the API suite unrunnable on a Windows dev box, so the documented validation
+ gate can only be completed on Linux or in CI. See Notes for the mechanism.
From 5729702ae1017d50c570b31cb59cb20e2fedac7a Mon Sep 17 00:00:00 2001
From: Heyoub
Date: Mon, 24 Aug 2026 12:48:12 -0400
Subject: [PATCH 24/64] chore(plans): record exact api test counts in
site-check-153 notes
Pins the numbers behind the unticked all-workspaces gate so a future
reader can tell a known Windows baseline from a real regression, and
notes the develop re-run that confirms it.
Co-Authored-By: Claude Fable 5
---
plans/site-check-153.md | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/plans/site-check-153.md b/plans/site-check-153.md
index 5a18097..1f0fe6d 100644
--- a/plans/site-check-153.md
+++ b/plans/site-check-153.md
@@ -136,10 +136,13 @@ One-line repo URL swap to `codeforphilly-ng`.
(To be populated at closeout. Recorded during implementation:)
-- **`apps/api` tests do not pass on Windows, independent of this plan.** Ten
- failures across `scrub-data.test.ts` (4), `internal-reload.test.ts` (4), and
- `store.test.ts` (2), on a tree where `git diff develop..HEAD -- apps/api
- packages/` is empty — this branch touches no API code. The mechanism is
+- **`apps/api` tests do not pass on Windows, independent of this plan.**
+ `apps/api` finishes 3 failed | 30 passed (33 files), 10 failed | 413 passed
+ (423 tests) — the ten spread across `scrub-data.test.ts` (4),
+ `internal-reload.test.ts` (4), and `store.test.ts` (2), on a tree where
+ `git diff develop..HEAD -- apps/api packages/` is empty — this branch touches
+ no API code. Checking out `develop` and re-running `store.test.ts` there
+ reproduces its 2 failed | 11 passed exactly. The mechanism is
POSIX-isms in the test fixtures: `store.test.ts` injects a write failure by
pointing the private store at `/dev/null/impossible-path` and asserting the
transaction rejects, but on Windows that is an ordinary creatable directory,
From bb05e0f1c7ed4dd907c8add68295ddf6a27272a3 Mon Sep 17 00:00:00 2001
From: Heyoub
Date: Mon, 24 Aug 2026 12:52:56 -0400
Subject: [PATCH 25/64] chore(plans): record browser validation for
site-check-153
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Desktop header verified in headed Chrome at 1400px; the sheet verified via
its portal at desktop width because the automation harness could not shrink
a maximized window below md — same limitation web-shell.md's plan recorded.
Co-Authored-By: Claude Fable 5
---
plans/site-check-153.md | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/plans/site-check-153.md b/plans/site-check-153.md
index 1f0fe6d..9178493 100644
--- a/plans/site-check-153.md
+++ b/plans/site-check-153.md
@@ -120,7 +120,15 @@ One-line repo URL swap to `codeforphilly-ng`.
- [x] `npm run -w packages/shared build`, `npm run type-check`, and `npm run lint` clean.
- [x] `npm test` clean for the workspaces this plan touches: web 96/96, shared 75/75.
- [ ] `npm test` clean for **all** workspaces — `apps/api` cannot pass on the Windows dev box used here (see Notes); needs a Linux run or CI to close.
-- [ ] Browser test: desktop header order + mobile sheet padding at < md, both breakpoints.
+- [x] Browser test (headed Chrome, Vite dev server): desktop header order,
+ spacing, GitHub icon, and Volunteer-rightmost verified at 1400px; the
+ sheet verified open — "Menu" title, padded nav/search, no title/close
+ collision — and Escape closes it. Caveat: the harness could not shrink
+ the (maximized) window below md, so the sheet was opened via its
+ CSS-hidden trigger at desktop width. The sheet is a fixed `w-72`
+ portal, so its rendering is identical at < md; the < md *header bar*
+ (logo + auth + hamburger row) still rides on the jsdom tests, same
+ limitation `web-shell.md:109` recorded.
## Risks
From e092de3752184545aa25a5eb998e5167d6563630 Mon Sep 17 00:00:00 2001
From: Heyoub
Date: Mon, 24 Aug 2026 12:54:45 -0400
Subject: [PATCH 26/64] chore(plans): mark site-check-153 done (PR #154)
Co-Authored-By: Claude Fable 5
---
plans/site-check-153.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/plans/site-check-153.md b/plans/site-check-153.md
index 9178493..021ed1a 100644
--- a/plans/site-check-153.md
+++ b/plans/site-check-153.md
@@ -1,10 +1,11 @@
---
-status: in-progress
+status: done
depends: []
specs:
- specs/behaviors/app-shell.md
- specs/screens/volunteer.md
issues: [153]
+pr: 154
---
# Plan: site check — header order, mobile sheet padding, dead outbound links
From eb1cbc3ffd6ba2f6e767895986eef32ace91d251 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 20:59:56 -0400
Subject: [PATCH 27/64] docs: repoint stale codeforphilly-rewrite GitHub URLs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The repo was renamed to codeforphilly-ng; the old
github.com/CodeForPhilly/codeforphilly-rewrite URLs in the setup
snippets and the cutover announcement are a hard 404. Only the GitHub
URLs change here — the k8s namespace and package name still carry the
old slug on purpose.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
docs/operations/cutover-announcement.md | 2 +-
specs/architecture.md | 4 ++--
specs/behaviors/storage.md | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/operations/cutover-announcement.md b/docs/operations/cutover-announcement.md
index 6fd73cc..1adf51b 100644
--- a/docs/operations/cutover-announcement.md
+++ b/docs/operations/cutover-announcement.md
@@ -116,7 +116,7 @@ What you can do now:
- Use the Slack workspace as normal; SSO is now backed by our own IdP
Bugs / weirdness: file an issue on
-https://github.com/CodeForPhilly/codeforphilly-rewrite/issues or just reply
+https://github.com/CodeForPhilly/codeforphilly-ng/issues or just reply
here.
```
diff --git a/specs/architecture.md b/specs/architecture.md
index 1dbff64..d80b0bc 100644
--- a/specs/architecture.md
+++ b/specs/architecture.md
@@ -135,9 +135,9 @@ The web and api share Zod schemas for every request/response shape and every rec
The "no moving pieces" promise: a contributor needs git, Node, and two clones. No Docker compose, no database to install, no migrations to run.
```bash
-git clone https://github.com/CodeForPhilly/codeforphilly-rewrite.git
+git clone https://github.com/CodeForPhilly/codeforphilly-ng.git
git clone https://github.com/CodeForPhilly/codeforphilly-data-snapshot.git ../codeforphilly-data
-cd codeforphilly-rewrite
+cd codeforphilly-ng
npm install
npm run dev # api + web concurrently with watch
```
diff --git a/specs/behaviors/storage.md b/specs/behaviors/storage.md
index 334668e..b5d8a3f 100644
--- a/specs/behaviors/storage.md
+++ b/specs/behaviors/storage.md
@@ -84,7 +84,7 @@ A scrubbed snapshot of the data repo is published as a public tag (e.g., `snapsh
An `apps/api/scripts/scrub-data.ts` in the code repo produces the snapshot. The contributor bootstrap is:
```bash
-git clone https://github.com/CodeForPhilly/codeforphilly-rewrite.git
+git clone https://github.com/CodeForPhilly/codeforphilly-ng.git
git clone --bare https://github.com/CodeForPhilly/codeforphilly-data-snapshot.git ../codeforphilly-data
npm install
npm run dev # api + web boot, data already there
From 12dea00f513ff723951e86025291a72f138c29f4 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:00:03 -0400
Subject: [PATCH 28/64] chore(web): remove dead HomeStub page
Nothing has imported or routed it since the scaffold (every live screen
lives in src/screens/), and it carried the last stale
codeforphilly-rewrite link in the web bundle.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/src/pages/HomeStub.tsx | 29 -----------------------------
1 file changed, 29 deletions(-)
delete mode 100644 apps/web/src/pages/HomeStub.tsx
diff --git a/apps/web/src/pages/HomeStub.tsx b/apps/web/src/pages/HomeStub.tsx
deleted file mode 100644
index 63afc92..0000000
--- a/apps/web/src/pages/HomeStub.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-export function HomeStub() {
- return (
-
-
- Code for Philly is being rebuilt
-
-
- We are modernizing the platform. Check back soon for the full
- experience. In the meantime, you can browse our projects below.
-
- );
-}
From 9201cbe51c501508836e835d6c72ece0fdf1b797 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:00:12 -0400
Subject: [PATCH 29/64] test(web): settle AuthProvider before Volunteer tests
return
Three synchronous tests returned while AuthProvider's /api/auth/me fetch
was still in flight, so its state update landed outside act() and
warned on every run. Each now awaits the settled DOM the way the
fourth test already does.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/tests/Volunteer.test.tsx | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/apps/web/tests/Volunteer.test.tsx b/apps/web/tests/Volunteer.test.tsx
index 3a45b0c..af17c99 100644
--- a/apps/web/tests/Volunteer.test.tsx
+++ b/apps/web/tests/Volunteer.test.tsx
@@ -43,7 +43,7 @@ describe('Volunteer', () => {
);
}
- it('renders the hero headline', () => {
+ it('renders the hero headline', async () => {
renderVolunteer();
expect(
screen.getByRole('heading', {
@@ -51,22 +51,26 @@ describe('Volunteer', () => {
level: 1,
}),
).toBeInTheDocument();
+ // Let AuthProvider's /api/auth/me fetch settle before the test returns.
+ await screen.findByText(/browse 268 active projects/i);
});
- it('points "When we meet →" at the live Meetup group, not the dead GitBook page', () => {
+ it('points "When we meet →" at the live Meetup group, not the dead GitBook page', async () => {
renderVolunteer();
const link = screen.getByRole('link', { name: /when we meet/i });
expect(link).toHaveAttribute('href', MEETUP_URL);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ await screen.findByText(/browse 268 active projects/i);
});
- it('points "Read the guide →" at the partnerships repo, not the dead GitBook page', () => {
+ it('points "Read the guide →" at the partnerships repo, not the dead GitBook page', async () => {
renderVolunteer();
const link = screen.getByRole('link', { name: /read the guide/i });
expect(link).toHaveAttribute('href', START_PROJECT_URL);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ await screen.findByText(/browse 268 active projects/i);
});
it('has no codeforphilly.gitbook.io links anywhere on the screen', async () => {
From bebd03421099d0de06db1a8605898d194bdc3238 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:03:27 -0400
Subject: [PATCH 30/64] refactor(web): lift GitHubIcon into a shared component
AppHeader.tsx and LoginPlaceholder.tsx carried byte-identical copies of
the same SVG. One component under components/icons/ keeps the path
data in a single place for the next screen that needs it.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/src/components/AppHeader.tsx | 16 +---------------
apps/web/src/components/icons/GitHubIcon.tsx | 15 +++++++++++++++
apps/web/src/pages/LoginPlaceholder.tsx | 16 +---------------
3 files changed, 17 insertions(+), 30 deletions(-)
create mode 100644 apps/web/src/components/icons/GitHubIcon.tsx
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index 7991907..d7ae03d 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { SearchBox } from '@/components/SearchBox';
+import { GitHubIcon } from '@/components/icons/GitHubIcon';
import { useAuth } from '@/hooks/useAuth';
const GITHUB_URL = 'https://github.com/CodeForPhilly';
@@ -61,21 +62,6 @@ function MenuIcon() {
);
}
-function GitHubIcon() {
- return (
-
- );
-}
-
function AuthControls({ mobile = false }: { mobile?: boolean }) {
const { person, loading, signOut } = useAuth();
diff --git a/apps/web/src/components/icons/GitHubIcon.tsx b/apps/web/src/components/icons/GitHubIcon.tsx
new file mode 100644
index 0000000..6862c4e
--- /dev/null
+++ b/apps/web/src/components/icons/GitHubIcon.tsx
@@ -0,0 +1,15 @@
+/** GitHub mark, sized to sit inline with text and icon buttons. */
+export function GitHubIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx
index 5b82177..581185a 100644
--- a/apps/web/src/pages/LoginPlaceholder.tsx
+++ b/apps/web/src/pages/LoginPlaceholder.tsx
@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { GitHubIcon } from '@/components/icons/GitHubIcon';
import { useAuth } from '@/hooks/useAuth';
import { api, ApiError } from '@/lib/api';
@@ -46,21 +47,6 @@ const ERROR_MESSAGES: Record = {
),
};
-function GitHubIcon() {
- return (
-
- );
-}
-
function WhyGitHub() {
const [open, setOpen] = useState(false);
From ffdc5e9f5e9731fdb3f9820d98c407a1e3f7bb82 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:03:39 -0400
Subject: [PATCH 31/64] fix(web): close the mobile sheet on any navigation
The eleven per-item onClick closers missed the inline SearchBox's Enter
path, so "every item in the sheet closes the sheet when activated"
(specs/behaviors/app-shell.md) did not hold there. Derive the open
state from the location.key the sheet was opened at instead: any
client-side navigation closes it, including a same-path re-click.
Derived during render rather than in a useLocation effect because
react-hooks/set-state-in-effect flags the effect form; this is the
same state-sync pattern ProjectEdit.tsx uses. The two anchors that do
not change location (Contact mailto:, GitHub new tab) keep an explicit
closer. Tests cover a sheet link click and the search Enter path.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/src/components/AppHeader.tsx | 66 ++++++++-------------------
apps/web/tests/AppHeader.test.tsx | 33 ++++++++++++++
2 files changed, 52 insertions(+), 47 deletions(-)
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index d7ae03d..7f2b6e4 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { Link, NavLink } from 'react-router';
+import { Link, NavLink, useLocation } from 'react-router';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -202,7 +202,15 @@ function GitHubLink() {
}
export function AppHeader() {
- const [mobileOpen, setMobileOpen] = useState(false);
+ const location = useLocation();
+ // The sheet is open only for the location it was opened at, so any
+ // client-side navigation — a NavLink or Enter in the inline search —
+ // closes it without per-item onClick closers. Derived during render
+ // rather than synced in an effect (react-hooks/set-state-in-effect).
+ const [openedAtKey, setOpenedAtKey] = useState(null);
+ const mobileOpen = openedAtKey === location.key;
+ const setMobileOpen = (open: boolean) =>
+ setOpenedAtKey(open ? location.key : null);
return (
@@ -280,64 +288,32 @@ export function AppHeader() {
aria-label="Mobile navigation"
className="flex flex-col gap-2 px-4 min-h-0 overflow-y-auto"
>
- setMobileOpen(false)}
- >
+
Projects
- setMobileOpen(false)}
- >
+
Help Wanted
- setMobileOpen(false)}
- >
+
Members
About
- setMobileOpen(false)}
- >
+
Mission
- setMobileOpen(false)}
- >
+
Leadership
- setMobileOpen(false)}
- >
+
Code of Conduct
- setMobileOpen(false)}
- >
+
Hackathons
- setMobileOpen(false)}
- >
+
Sponsor
GitHub
- setMobileOpen(false)}
- >
+
Volunteer
diff --git a/apps/web/tests/AppHeader.test.tsx b/apps/web/tests/AppHeader.test.tsx
index 116d260..9f3c3c3 100644
--- a/apps/web/tests/AppHeader.test.tsx
+++ b/apps/web/tests/AppHeader.test.tsx
@@ -137,4 +137,37 @@ describe('AppHeader', () => {
'/volunteer',
);
});
+
+ it('closes the mobile sheet when a sheet link navigates', async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await user.click(screen.getByRole('button', { name: /open navigation menu/i }));
+ const nav = await screen.findByRole('navigation', { name: /mobile navigation/i });
+
+ await user.click(within(nav).getByRole('link', { name: 'Members' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog', { name: 'Menu' })).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes the mobile sheet when the inline search navigates', async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await user.click(screen.getByRole('button', { name: /open navigation menu/i }));
+ const dialog = await screen.findByRole('dialog', { name: 'Menu' });
+
+ // Scope to the sheet: jsdom applies no breakpoints, so the desktop
+ // search box is in the DOM too.
+ await user.type(
+ within(dialog).getByRole('searchbox', { name: /search the site/i }),
+ 'civic{Enter}',
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog', { name: 'Menu' })).not.toBeInTheDocument();
+ });
+ });
});
From 6cdccd1a1cac2d49d2cebe36c124ce95d4442354 Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:03:48 -0400
Subject: [PATCH 32/64] fix(web): describe the sheet dialog, drop its dead
width class
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Radix warned on every open that the dialog's aria-describedby pointed
at nothing; a visually-hidden SheetDescription satisfies it. The
className="w-72" on SheetContent never applied — the primitive's
data-[side=right]:w-3/4 and sm:max-w-sm win — so it goes.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/src/components/AppHeader.tsx | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index 7f2b6e4..ca3611d 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -11,6 +11,7 @@ import {
import {
Sheet,
SheetContent,
+ SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
@@ -276,11 +277,15 @@ export function AppHeader() {
-
+
{/* SheetHeader/SheetTitle carry the panel's own padding and give
- the underlying Radix dialog its accessible name. */}
+ the underlying Radix dialog its accessible name; the
+ visually-hidden description satisfies aria-describedby. */}
Menu
+
+ Site navigation
+
{/* min-h-0 + overflow-y-auto so the list stays reachable on
short viewports instead of overflowing the panel. */}
From b417c87a0a5e83b77925637777f39070d170fadc Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:03:57 -0400
Subject: [PATCH 33/64] fix(web): wrap the utility cluster in a navigation
landmark
Volunteer and the GitHub link left "Primary navigation" in this PR and
landed in a plain div, so they fell out of every navigation landmark.
A second nav labelled "Utility" keeps them reachable by landmark
without moving them back; app-shell.md's Accessibility section now
names both landmarks.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/src/components/AppHeader.tsx | 7 +++++--
specs/behaviors/app-shell.md | 1 +
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index ca3611d..a781e09 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -250,7 +250,10 @@ export function AppHeader() {
{/* Desktop utility cluster: GitHub, search, auth, then the Volunteer
CTA pinned rightmost (specs/behaviors/app-shell.md). */}
-
diff --git a/specs/behaviors/app-shell.md b/specs/behaviors/app-shell.md
index 2262f09..95c0b52 100644
--- a/specs/behaviors/app-shell.md
+++ b/specs/behaviors/app-shell.md
@@ -190,6 +190,7 @@ It does not block initial paint waiting on `me`. Auth controls render skeletons
- All dropdowns are keyboard-navigable
- The mobile sheet traps focus while open and returns it to the trigger on close
- The mobile sheet is a dialog with the accessible name "Menu"
+- At ≥ md the header exposes two navigation landmarks: "Primary navigation" (the content cluster) and "Utility" (GitHub, search, auth, Volunteer)
- Every icon-only control carries an accessible name; controls with visible text use that text as their accessible name rather than duplicating it in a label
## Print
From d5a4221932f2158baba7a334df9829dff0a8ac3e Mon Sep 17 00:00:00 2001
From: Chris Alfano
Date: Tue, 8 Sep 2026 21:04:09 -0400
Subject: [PATCH 34/64] fix(web): keep the header on one line between md and lg
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tailwind's container is 768px wide across the whole 768–1023 range
(736px content box). Measured at 768: logo 128 + nav 297 + gaps 48 +
utility cluster 387 = 860, so the document scrolled to 845px and
"Help Wanted" wrapped onto two lines.
Hide the GitHub icon link below lg (the mobile sheet keeps its own
GitHub row) and narrow the desktop search to w-24 there, restoring
w-48 / focus-within:w-72 at lg. The results dropdown is anchored
right-0 with min-w-72 instead of stretching left-0 right-0, so it
stays 288px wide over the narrow input — the width it already had at
lg. After: 728px used at 768, no horizontal scroll; 1024 fits with
the icon and wide search back.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
---
apps/web/src/components/AppHeader.tsx | 5 ++++-
apps/web/src/components/SearchBox.tsx | 4 ++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index a781e09..bdde238 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -189,7 +189,10 @@ const navLinkClass = ({ isActive }: { isActive: boolean }) =>
function GitHubLink() {
return (
-
+ // Desktop-only: between md and lg the header has no room for it (the
+ // utility cluster would push "Help Wanted" onto two lines); the mobile
+ // sheet carries its own GitHub row.
+
{loading && results.length === 0 && (