From f0b024086f6fb062bc2d760453f05cd7f885a22c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 4 Jul 2026 12:43:13 -0400 Subject: [PATCH 01/64] =?UTF-8?q?docs(plans):=20plan=20gitsheets=201.4.1?= =?UTF-8?q?=20=E2=86=92=202.x=20bump=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version-bump-only scope: 2.x is a Rust-core engine rewrite behind an unchanged Node API. Real work is the hologit-drop blob-write migration + two documented byte re-baselines (integer underscores, markdown bodies). The three cache workarounds are ported, not deleted — gitsheets#184 (the per-sheet refresh API that would let them go) is open and not in 2.x. Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/gitsheets-2x-bump.md | 113 +++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 plans/gitsheets-2x-bump.md diff --git a/plans/gitsheets-2x-bump.md b/plans/gitsheets-2x-bump.md new file mode 100644 index 0000000..5e9daa2 --- /dev/null +++ b/plans/gitsheets-2x-bump.md @@ -0,0 +1,113 @@ +--- +status: in-progress +depends: [] +specs: + - specs/behaviors/storage.md +issues: + - 150 +pr: +--- + +# Plan: upgrade gitsheets 1.4.1 → 2.x (Rust core) + +## Scope + +Bump `gitsheets` from `^1.4.1` to `^2.2.0` (the Rust-core line). This plan is +the **version bump only** — the "retire the three cache workarounds" half of +issue #150 is **out of scope and blocked** on upstream `gitsheets#184` +(per-sheet refresh API), which is OPEN, unassigned, and **not shipped in 2.x** +(verified: 2.x `Sheet` still carries the pre-commit `dataTree` snapshot). The +workarounds are therefore **ported/verified, not deleted**. + +## What actually changes in 2.x (verified against the gitsheets source) + +- **The Node public API is deliberately unchanged** — 2.x is an engine rewrite + behind the same `openRepo`/`openStore`/`Sheet`/`Transaction` surface + (`specs/rust-core.md` → "No consumer-visible public-API change"). So most of + our code compiles as-is. +- **`hologit` is dropped as a gitsheets dependency** (2.x deps are + `@gitsheets/core-napi` + csv/rfc6902/sort-keys/yargs — no hologit). This is + the **one real breaking change for us**: our avatar-blob-write path imports + `BlobObject` from `hologit` and uses `publicRepo.hologitRepo`. +- **Two deliberate one-time byte re-baselines** (data-level, lossless — values + unchanged, only formatting): + 1. **Canonical TOML**: Rust `toml`/`toml_edit` drops integer underscores + (`legacyId = 31_618` → `31618`) across all sheets (matches gitsheets#196). + 2. **Markdown body**: content-typed sheets (our **blog-posts**) normalize the + body via native `dprint` instead of `markdownlint`. + +## Work + +### 1. Dependency bump + +- `npm install gitsheets@^2.2.0 -w apps/api`. Commit the generated + package.json + lock change first (drops hologit transitively). + +### 2. Migrate the blob-write path off `hologit` (the real code change) + +Replace the `hologit` `BlobObject.write(publicRepo.hologitRepo, buf)` pattern +with the 2.x `Repository.writeBlob(buf): Promise` + `setAttachment` +API. Sites: + +- `apps/api/src/routes/people.ts:19,407,430-431` (avatar upload) +- `apps/api/scripts/import-laddr/importer.ts:95,503,516-517,583-590` (legacy + avatar + media import) +- Drop the `as unknown as string` casts — 2.x `writeBlob` takes a `Buffer`. +- Confirm the exact 2.x `setAttachment(s)` signature in the gitsheets `Sheet` + API (`AttachmentBlobHandle` / `BlobHandle`) and wire accordingly. +- Grep for any other `hologit` / `hologitRepo` / `BlobObject` / `TreeObject` + references and migrate; ensure `hologit` is NOT needed as a direct dep. + +### 3. Verify (do NOT delete) the #184 workarounds still compile + work + +- `apps/api/src/store/store.ts` `swapPublic()` — re-opens the store via + `openPublicStore`; API unchanged, should be fine. Confirm it doesn't touch + `hologitRepo`. +- `apps/api/src/routes/attachments.ts` — raw `git cat-file` (git-level, not + gitsheets) — unaffected; confirm. +- `apps/api/src/lib/data-repo-lock.ts` — our own mutex — unaffected; confirm. + +### 4. Incidental cast checks (issue #150) + +- `apps/api/src/store/public.ts` `asValidator()` (Zod v4 ↔ `StandardSchemaV1`). + 2.x still exports `StandardSchemaV1`/`ValidatorMap`; keep the cast if still + needed, simplify if 2.x makes it clean. Don't force it. + +### 5. Validation + +- `npm run -w packages/shared build` (exports map points at dist), then + `npm run type-check` + `npm run lint` clean. +- **Full api test suite green.** The byte re-baseline will break any test that + asserts exact TOML bytes containing integer underscores, or exact blog-post + body bytes — fix those to the new canonical form (they're re-baseline + updates, not behavior changes; note each in the commit). +- **Byte-parity check on real data**: load the `published` import under 2.x and + confirm the change is **lossless** — parsed values identical to 1.4.1, only + the documented re-baselines (integer underscores, markdown bodies) differ. + (Mirror the approach used for the 1.4.1 swap.) +- Sanity-check the migrated blob path end to end: an avatar upload writes both + `avatar.jpg` + `avatar-128.jpg` attachments with the right `avatarKey`. + +## Out of scope / follow-ups + +- **Retiring `swapPublic` / attachments `git cat-file` / `data-repo-lock`** — + blocked on `gitsheets#184`; separate effort once that lands upstream. Keep + #150's second half open (or split it out). +- **Data-repo re-normalization commit** — under 2.x, records re-serialize + without integer underscores as they're written, so the repo drifts to mixed + format until fully rewritten. A deliberate one-time re-normalize (rewrite all + records) is cleaner but is a **data-ops task on `codeforphilly-data`**, not + part of this code bump. Flag it; do it deliberately (likely bundled with the + cutover data prep). +- **Timing note:** 2.x is days old (Rust rewrite) and we're near production + cutover (#54). This bump is validated but should merge on a deliberate + decision, not reflexively before cutover. + +## Validation checklist + +- [ ] deps bumped; hologit gone from lock +- [ ] blob-write path migrated off hologit; casts removed +- [ ] workarounds verified (compile + covered by tests) +- [ ] type-check + lint clean +- [ ] full api suite green (re-baseline test updates noted) +- [ ] byte-parity on `published` = lossless (only documented re-baselines) From 2b3bff7b50a43dad3c3df1c11ea75c5f870194db Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 4 Jul 2026 12:46:55 -0400 Subject: [PATCH 02/64] chore(deps): bump gitsheets to 2.x (Rust core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade gitsheets ^1.4.1 → ^2.2.0. The 2.x line rewrites the core in Rust (via @gitsheets/core-napi) but keeps the Node public API surface (openRepo/openStore/Sheet/Transaction) unchanged. Command run: npm install gitsheets@^2.2.0 -w apps/api hologit is dropped as a transitive dependency in 2.x; it no longer appears in the lockfile. Code migration (avatar blob-write path) is in the next commit. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/package.json | 2 +- package-lock.json | 1171 +++++------------------------------------ 2 files changed, 130 insertions(+), 1043 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index b5425ed..a7ff31e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,7 +35,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", - "gitsheets": "^1.4.1", + "gitsheets": "^2.2.0", "jose": "^6.2.3", "resend": "^6.12.4", "samlify": "^2.13.0", diff --git a/package-lock.json b/package-lock.json index 1dbe57d..aa3e9f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", - "gitsheets": "^1.4.1", + "gitsheets": "^2.2.0", "jose": "^6.2.3", "resend": "^6.12.4", "samlify": "^2.13.0", @@ -1145,15 +1145,6 @@ "resolved": "apps/web", "link": true }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -1294,17 +1285,6 @@ "node": ">=20.19.0" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, "node_modules/@dotenvx/dotenvx": { "version": "1.66.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.66.0.tgz", @@ -2534,6 +2514,119 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@gitsheets/core-napi": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi/-/core-napi-0.1.1.tgz", + "integrity": "sha512-ZoJGzwUo8K0ok/XiQpGJ5k4lEuss21ZcDP8+pIGHMsdMkXmLlXNZhPij1Z0MtvFJIu3nu3mCqWVFR8cJf5jNsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@gitsheets/core-napi-darwin-arm64": "0.1.1", + "@gitsheets/core-napi-darwin-x64": "0.1.1", + "@gitsheets/core-napi-linux-arm64-gnu": "0.1.1", + "@gitsheets/core-napi-linux-x64-gnu": "0.1.1", + "@gitsheets/core-napi-linux-x64-musl": "0.1.1", + "@gitsheets/core-napi-win32-x64-msvc": "0.1.1" + } + }, + "node_modules/@gitsheets/core-napi-darwin-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi-darwin-arm64/-/core-napi-darwin-arm64-0.1.1.tgz", + "integrity": "sha512-1rHQT78zFwOIeD2Gf9LWNmIzm17wibxYXz5U8F0SOY51W+BIfklnYanfSywWHmE1FclqIVOE3ngFKZEiH/XgpQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@gitsheets/core-napi-darwin-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi-darwin-x64/-/core-napi-darwin-x64-0.1.1.tgz", + "integrity": "sha512-YDcId/vr8kw5hem3S78XVlxmdv/Nc7d8HaTSA2PSRVoMUHpjo3apx7g4agjthrKW5mUms14kKGOzG8s2Hn/G5w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@gitsheets/core-napi-linux-arm64-gnu": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi-linux-arm64-gnu/-/core-napi-linux-arm64-gnu-0.1.1.tgz", + "integrity": "sha512-qfGKIccxWnFPiyJ/GCuU1H+YVejdcfBGKcjKlHn/c9A8CUglCfXhruIYhOa0Q/AKbdVcIumG1UQ+QcdJkHBPCw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@gitsheets/core-napi-linux-x64-gnu": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi-linux-x64-gnu/-/core-napi-linux-x64-gnu-0.1.1.tgz", + "integrity": "sha512-G+7j08FLBIGzbjZK3BRZQ0sI8/i4r4CuPrESt2HZtBclsVWGEzx2X6avnLUlAOFZZVphPZgq6kqMxDLSUMl4og==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@gitsheets/core-napi-linux-x64-musl": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi-linux-x64-musl/-/core-napi-linux-x64-musl-0.1.1.tgz", + "integrity": "sha512-6cdOSsSGmpXq9raKQRv8Npro393H0FfvSbPEvt1V39ebEGOuhYSDr+H0UYH6rxv4IcYeCOPjdgZMOYTuhD94oA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@gitsheets/core-napi-win32-x64-msvc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gitsheets/core-napi-win32-x64-msvc/-/core-napi-win32-x64-msvc-0.1.1.tgz", + "integrity": "sha512-+dDxvrXbtkP6D1Ghpnk5F+N/MGkgTClng0SxuoUmy7Bfi1RYu5Syj8kE1Dz91dGLvVU4U/+SK44hJMv/OOvONw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -2624,12 +2717,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "license": "ISC" - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -5328,16 +5415,6 @@ "node": ">=14.0.0" } }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", - "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -5833,12 +5910,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/katex": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", - "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5898,12 +5969,6 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6376,18 +6441,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -6445,12 +6498,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, "node_modules/argon2": { "version": "0.44.0", "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", @@ -6526,25 +6573,11 @@ "node": ">=4" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -6576,18 +6609,6 @@ "fastq": "^1.17.1" } }, - "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -6779,15 +6800,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -6974,31 +6986,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -7082,19 +7069,6 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7113,48 +7087,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-string/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -7166,6 +7098,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7193,12 +7126,6 @@ "node": ">=20" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -7433,18 +7360,6 @@ "node": "*" } }, - "node_modules/debounce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", - "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7580,6 +7495,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7709,12 +7625,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -7837,6 +7747,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8567,15 +8478,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8593,12 +8495,6 @@ } } }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -8741,36 +8637,11 @@ "dev": true, "license": "ISC" }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8833,12 +8704,6 @@ "node": ">=14.14" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8984,18 +8849,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/git-client": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/git-client/-/git-client-1.12.1.tgz", - "integrity": "sha512-Px7HE2ug+IKiOhNE/vezkwpDE7IUYHnoRHQfds2BlRf6CZglNq7lmlxVoRtprJ4ZHTq0ZWrQLZtrcMxFw18zvw==", - "license": "MIT", - "dependencies": { - "async-exit-hook": "^2.0.1", - "mz": "^2.7.0", - "rusha": "^0.8.14", - "semver": "^7.6.3" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -9003,20 +8856,15 @@ "license": "MIT" }, "node_modules/gitsheets": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/gitsheets/-/gitsheets-1.4.1.tgz", - "integrity": "sha512-/GIsDgsjweXcNk8ThYjAsWYctGr/xn0iVFE2J4u7yvJgmQdLSsOdLgqAwlny8MinhStniqpPm1XE+tvjt615JQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/gitsheets/-/gitsheets-2.2.0.tgz", + "integrity": "sha512-Ul04Ap8uA5GjljA6fM3VBBYCb6lYDaXSxpLdmNHDoac3JmV11q7j6F89sZqdla0/P8yQGjVGvyptcu0PTBdCgg==", "license": "Apache-2.0", "dependencies": { - "@iarna/toml": "^2.2.5", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", + "@gitsheets/core-napi": "^0.1.0", "csv-parse": "^6.2.1", "csv-stringify": "^6.7.0", - "hologit": "^0.50.2", - "markdownlint": "^0.40.0", "rfc6902": "^5.2.0", - "smol-toml": "^1.7.0", "sort-keys": "^6.0.0", "yargs": "^18.0.0" }, @@ -9147,27 +8995,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9181,34 +9008,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/globals": { "version": "17.6.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", @@ -9249,38 +9048,6 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/hab-client": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/hab-client/-/hab-client-1.1.3.tgz", - "integrity": "sha512-CZzvibCCQqwk3XZeNh2DWotOcnGViOjTny7NQAkSZid014OGZu+gfhLmTAj2qnUxDKr/ZImRauFLorFR38IwGQ==", - "license": "MIT", - "dependencies": { - "axios": "^1.3.4", - "semver": "^7.3.8", - "underscore": "^1.13.6" - } - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9307,6 +9074,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9421,162 +9189,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hologit": { - "version": "0.50.2", - "resolved": "https://registry.npmjs.org/hologit/-/hologit-0.50.2.tgz", - "integrity": "sha512-9StuAaE8TkdnJE/cA5vdq+pwt/hZH1JGa9qsGfAz7rvwsXrVlQT/lBJd+pedpJHOJgPdA4NXC0lqljFzOWV+7A==", - "license": "MIT", - "os": [ - "darwin", - "linux" - ], - "dependencies": { - "@iarna/toml": "^2.2.5", - "async-exit-hook": "^2.0.1", - "axios": "^1.14.0", - "chokidar": "^5.0.0", - "debounce": "^3.0.0", - "fb-watchman": "^2.0.2", - "git-client": "^1.12.0", - "hab-client": "^1.1.3", - "handlebars": "^4.7.9", - "minimatch": "^10.2.4", - "mz": "^2.7.0", - "mz-modules": "^2.1.0", - "object-squish": "^1.1.0", - "parse-url": "^11.1.0", - "shell-quote-word": "^1.0.1", - "sort-keys": "^6.0.0", - "toposort": "^2.0.2", - "winston": "^3.19.0", - "yargs": "^18.0.0" - }, - "bin": { - "git-holo": "bin/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/hologit/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/hologit/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/hologit/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/hologit/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/hologit/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hologit/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/hologit/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/hologit/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/hologit/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/hono": { "version": "4.12.19", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", @@ -9629,19 +9241,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -9732,17 +9331,6 @@ "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -9773,46 +9361,12 @@ "node": ">= 10" } }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -9858,16 +9412,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -10220,31 +9764,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10264,21 +9783,6 @@ "node": ">=6" } }, - "node_modules/ko-sleep": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/ko-sleep/-/ko-sleep-1.1.4.tgz", - "integrity": "sha512-s05WGpvvzyTuRlRE8fM7ru2Z3O+InbJuBcckTWKg2W+2c1k6SnFa3IfiSSt0/peFrlYAXgNoxuJWWVNmWh+K/A==", - "license": "MIT", - "dependencies": { - "ms": "*" - } - }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10641,23 +10145,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -10716,72 +10203,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/markdownlint": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz", - "integrity": "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==", - "license": "MIT", - "dependencies": { - "micromark": "4.0.2", - "micromark-core-commonmark": "2.0.3", - "micromark-extension-directive": "4.0.0", - "micromark-extension-gfm-autolink-literal": "2.1.0", - "micromark-extension-gfm-footnote": "2.1.0", - "micromark-extension-gfm-table": "2.1.1", - "micromark-extension-math": "3.1.0", - "micromark-util-types": "2.0.2", - "string-width": "8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/DavidAnson" - } - }, - "node_modules/markdownlint/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/markdownlint/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdownlint/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/marked": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", @@ -11151,25 +10572,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-extension-directive": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", - "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/micromark-extension-gfm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", @@ -11291,25 +10693,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-extension-math": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", - "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", - "license": "MIT", - "dependencies": { - "@types/katex": "^0.16.0", - "devlop": "^1.0.0", - "katex": "^0.16.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -11724,6 +11107,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11733,6 +11117,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -11817,18 +11202,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -11894,33 +11267,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/mz-modules": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mz-modules/-/mz-modules-2.1.0.tgz", - "integrity": "sha512-sjk8lcRW3vrVYnZ+W+67L/2rL+jbO5K/N6PFGIcLWTiYytNr22Ah9FDXFs+AQntTM1boZcoHi5qS+CV1seuPog==", - "license": "MIT", - "dependencies": { - "glob": "^7.1.2", - "ko-sleep": "^1.0.3", - "mkdirp": "^0.5.1", - "pump": "^3.0.0", - "rimraf": "^2.6.1" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -11961,12 +11307,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -12037,12 +11377,6 @@ "node-gyp-build-test": "build-test.js" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "license": "MIT" - }, "node_modules/node-releases": { "version": "2.0.44", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", @@ -12107,12 +11441,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-squish": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object-squish/-/object-squish-1.1.0.tgz", - "integrity": "sha512-u+gd7R29OnIETm+tv546B0wq9SQFWWSnzdN8AunFrrOI4QV2q9+blpQnL9QWZbCXh3oV7LGCkWe5P3BpzVTfDA==", - "license": "BSD-2-Clause" - }, "node_modules/object-treeify": { "version": "1.1.33", "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", @@ -12163,15 +11491,6 @@ "wrappy": "1" } }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -12366,31 +11685,6 @@ "node": ">=6" } }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -12421,27 +11715,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-path": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz", - "integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==", - "license": "MIT", - "dependencies": { - "protocols": "^2.0.0" - } - }, - "node_modules/parse-url": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-11.1.0.tgz", - "integrity": "sha512-UYn/lNb1bmkYiM6UaqEbHl9T/VIYreM2Vm79MGZ2soUOXOoOq7qxoTwx8C8p9V4Ko2DLcsVnEhRJzhxsF2kssg==", - "license": "MIT", - "dependencies": { - "parse-path": "^7.1.0" - }, - "engines": { - "node": ">=14.13.0" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -12495,15 +11768,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12829,12 +12093,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/protocols": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", - "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", - "license": "MIT" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -12857,15 +12115,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -13202,19 +12451,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -13465,19 +12701,6 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -13572,12 +12795,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rusha": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz", - "integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==", - "license": "MIT" - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -13943,12 +13160,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shell-quote-word": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/shell-quote-word/-/shell-quote-word-1.0.1.tgz", - "integrity": "sha512-lT297f1WLAdq0A4O+AknIFRP6kkiI3s8C913eJ0XqBxJbZPGWUNkRQk2u8zk4bEAjUJ5i+fSLwB6z1HzeT+DEg==", - "license": "MIT" - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -14091,18 +13302,6 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, - "node_modules/smol-toml": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", - "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -14123,9 +13322,9 @@ } }, "node_modules/sort-keys": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-6.0.0.tgz", - "integrity": "sha512-ueSlHJMwpIw42CJ4B11Uxzh/S0p0AlOyiNktlv2KOu5e1JpUE6DlC4AAUjXqesHdBRv/g0wC9Q4vwq0NP2pA9w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-6.0.1.tgz", + "integrity": "sha512-w7xWRu8U9MneKNna8ptG194jp9PLtbd/Rl6gwrmbK4yUeKbE66a64rHgl0iKTBBDr/hpanx7zMGP1Qo8MRkc/w==", "license": "MIT", "dependencies": { "is-plain-obj": "^4.1.0" @@ -14174,15 +13373,6 @@ "node": ">= 10.x" } }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -14464,33 +13654,6 @@ "node": ">=6" } }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -14606,12 +13769,6 @@ "node": ">=0.6" } }, - "node_modules/toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", - "license": "MIT" - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -14657,15 +13814,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -14881,25 +14029,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -15451,42 +14580,6 @@ "node": ">=8" } }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -15497,12 +14590,6 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", From a458f86fb9e6ff56743e3b6e87042b782b182418 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 4 Jul 2026 12:49:02 -0400 Subject: [PATCH 03/64] feat(api): migrate blob-write path from hologit to gitsheets 2.x API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitsheets 2.x drops hologit as a dependency. Replace the BlobObject.write(hologitRepo, buf) pattern with the 2.x surface: const blob = await repo.writeBlob(buffer: Buffer) // → BlobHandle await sheet.setAttachments(record, { 'name.jpg': blob }) Sites migrated: - apps/api/src/routes/people.ts: avatar upload (original + 128 thumb) - apps/api/scripts/import-laddr/importer.ts: legacy avatar + blog-media The `as unknown as string` casts are removed — 2.x writeBlob takes a Buffer natively, matching the actual runtime types throughout. The three workarounds for gitsheets#184 (swapPublic in store.ts, git cat-file in attachments.ts, data-repo-lock.ts) are unaffected and confirmed to still compile. They are kept as-is pending upstream resolution. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/scripts/import-laddr/importer.ts | 21 +++++++-------------- apps/api/src/routes/people.ts | 18 +++++------------- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/apps/api/scripts/import-laddr/importer.ts b/apps/api/scripts/import-laddr/importer.ts index 80b4386..d4f7553 100644 --- a/apps/api/scripts/import-laddr/importer.ts +++ b/apps/api/scripts/import-laddr/importer.ts @@ -92,7 +92,7 @@ import { type TranslateCtx, type Warnings, } from './translators.js'; -import { BlobObject } from 'hologit'; +import type { BlobHandle } from 'gitsheets'; // --------------------------------------------------------------------------- // Public types @@ -187,7 +187,7 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise>['repo'] | null = null; let existingIds: ExistingIds; @@ -500,7 +500,6 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise 0) { - const blobs: Record = {}; + const blobs: Record = {}; for (const a of artifacts) { - // BlobObject.write hashes the buffer into the git object DB. - // Same `as unknown as string` cast as the avatar route — the - // declared signature is too narrow; the underlying - // git-client `$putBlob` accepts Buffer at runtime. - blobs[a.filename] = await BlobObject.write( - hologit, - a.bytes as unknown as string, - ); + // repo.writeBlob hashes the Buffer into the git object DB. + blobs[a.filename] = await publicRepo.writeBlob(a.bytes); } await tx['blog-posts'].setAttachments(record, blobs); } diff --git a/apps/api/src/routes/people.ts b/apps/api/src/routes/people.ts index 7bff95a..ccd4dae 100644 --- a/apps/api/src/routes/people.ts +++ b/apps/api/src/routes/people.ts @@ -16,7 +16,6 @@ import { computePersonPermissions, getCallerSession } from '../services/permissi import { buildTransactionOptions } from '../store/commit-meta.js'; import type { UpdatePersonInput } from '../services/person.write.js'; import { AVATAR_ALLOWED_MIME, processAvatar } from '../lib/avatar.js'; -import { BlobObject } from 'hologit'; import type { Person } from '@cfp/shared/schemas'; import { PersonSchema } from '@cfp/shared/schemas'; import { StateApply } from '../store/state-apply.js'; @@ -404,7 +403,6 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { const newAvatarKey = `people/${person.slug}/avatar.jpg`; const stateApply = new StateApply(); - const hologit = fastify.publicRepo.hologitRepo; let updatedPerson: Person = person; await fastify.store.transact( @@ -418,17 +416,11 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { }), async (tx) => { // Write the two attachment blobs into the gitsheets transaction - // tree. BlobObject.write hashes the buffer into the git object DB - // via `git hash-object -w`; the tx-level setAttachments then wires - // the blob refs into the post-commit tree at the conventional path. - // - // BlobObject.write's TypeScript signature declares `content: string` - // but the underlying `git-client` `$putBlob` spawns `git hash-object - // --stdin -w` and pipes `content` to stdin, which accepts both - // strings and Buffers at runtime. Cast to match the declared shape; - // hologit's type would tighten upstream eventually. - const originalBlob = await BlobObject.write(hologit, processed.original as unknown as string); - const thumbnailBlob = await BlobObject.write(hologit, processed.thumbnail as unknown as string); + // tree. repo.writeBlob hashes the buffer into the git object DB; + // the tx-level setAttachments then wires the blob refs into the + // post-commit tree at the conventional path. + const originalBlob = await fastify.publicRepo.writeBlob(processed.original); + const thumbnailBlob = await fastify.publicRepo.writeBlob(processed.thumbnail); await tx.public.people.setAttachments(person, { 'avatar.jpg': originalBlob, 'avatar-128.jpg': thumbnailBlob, From 2838aa7055dbe2ed142e6f38082724c229193bde Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 4 Jul 2026 13:22:22 -0400 Subject: [PATCH 04/64] fix(store): strip null/undefined keys before gitsheets 2.x marshal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitsheets 2.x (Rust core) refuses to marshal a null- or undefined- valued field to TOML — `serializeRecords`/`upsert` throw "cannot marshal JS value of type Null/Undefined to a TOML value". gitsheets 1.4.1 (`@iarna/toml`) silently dropped such keys, so they were never written to disk (verified against the on-disk `published` snapshot — no record carries a null-valued key). Our Zod schemas mark optional fields `.nullable().optional()` and the write services normalize cleared fields to `?? null`, so under 2.x every write of a record with a cleared optional field threw a 500. This surfaced as 16 test failures across write-api, people-lifecycle, and import-laddr (all "cannot marshal ... Undefined/Null"). Fix: wrap the per-sheet Standard Schema validator in openPublicStore so the validated record has null/undefined-valued keys stripped (recursively) before it reaches the core marshaller. gitsheets runs the validator host-side and marshals its output, so this is the single authoritative write boundary. The result is byte-identical to 1.4.1's on-disk form: an absent optional field is an absent TOML key. This is not one of the two documented 2.x re-baselines (integer underscores, markdown bodies) — it's an undocumented marshal-contract change in the Rust core that the upgrade plan did not anticipate. Adds a focused store test asserting null-valued keys are dropped and present fields survive (also pins the integer-underscore re-baseline). Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/store/public.ts | 63 ++++++++++++++++++++++++++++++++++-- apps/api/tests/store.test.ts | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/apps/api/src/store/public.ts b/apps/api/src/store/public.ts index 348caac..3d752a7 100644 --- a/apps/api/src/store/public.ts +++ b/apps/api/src/store/public.ts @@ -33,7 +33,49 @@ import type { import type { Project } from '@cfp/shared/schemas'; /** - * Cast a Zod v4 schema to gitsheets' StandardSchemaV1. + * Recursively drop `null` / `undefined`-valued keys from a record. + * + * gitsheets 2.x (Rust core) refuses to marshal `null` or `undefined` field + * values — `serializeRecords`/`upsert` throw `cannot marshal JS value of type + * Null/Undefined to a TOML value`. gitsheets 1.4.1 (`@iarna/toml`) silently + * dropped such keys instead, so they were never written to disk: an absent + * optional field is simply an absent TOML key (verified against the on-disk + * `published` snapshot — no record carries a `null`-valued key). + * + * Our Zod schemas mark optional fields `.nullable().optional()` and the write + * services normalize "cleared" fields to `?? null`. To keep the on-disk form + * byte-identical to 1.4.1 (and to keep those `?? null` write paths working), + * we strip null/undefined keys here, at the single write boundary, before the + * record reaches the core marshaller. Nested tables (objects) are cleaned + * recursively; arrays are passed through untouched (TOML has no null in + * arrays, and our schemas never emit sparse arrays). + * + * `null` as an explicit "delete this field" signal only exists for + * `Sheet.patch` (RFC 7396 merge-patch); we don't use `patch`, so stripping on + * the full-record `upsert` path is unambiguous. + */ +function stripNullish(value: unknown): unknown { + if (value === null || value === undefined) return undefined; + if (Array.isArray(value)) return value; + if (typeof value === 'object' && !(value instanceof Date)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const cleaned = stripNullish(v); + if (cleaned !== undefined) out[k] = cleaned; + } + return out; + } + return value; +} + +/** + * Cast a Zod v4 schema to gitsheets' StandardSchemaV1, wrapping its validator + * so the validated record has null/undefined-valued keys stripped before it + * reaches gitsheets' core marshaller. + * + * gitsheets runs the Standard Schema validator host-side and marshals the + * validator's *output* — so stripping here (rather than at every upsert call + * site) is the single, authoritative write boundary. See `stripNullish`. * * Zod v4 implements the Standard Schema interface at runtime, but TypeScript * cannot prove that Zod's Result type is assignable to gitsheets' narrow @@ -41,7 +83,24 @@ import type { Project } from '@cfp/shared/schemas'; * shape. Both are correct at runtime; the cast is safe. */ function asValidator>(schema: unknown): StandardSchemaV1 { - return schema as StandardSchemaV1; + const inner = schema as StandardSchemaV1; + const innerValidate = inner['~standard'].validate; + return { + ...inner, + '~standard': { + ...inner['~standard'], + validate: (value: unknown) => { + const result = innerValidate(value); + const strip = ( + r: Awaited>, + ): Awaited> => + r.issues === undefined + ? { value: stripNullish(r.value) as T } + : r; + return result instanceof Promise ? result.then(strip) : strip(result); + }, + }, + }; } /** Typed validator map for openStore. */ diff --git a/apps/api/tests/store.test.ts b/apps/api/tests/store.test.ts index 83b884e..7e4e150 100644 --- a/apps/api/tests/store.test.ts +++ b/apps/api/tests/store.test.ts @@ -11,10 +11,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { openStore } from 'gitsheets'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + import { PersonSchema, ProjectSchema } from '@cfp/shared/schemas'; import { FilesystemPrivateStore } from '../src/store/private/filesystem.js'; import { Store } from '../src/store/store.js'; +import { openPublicStore } from '../src/store/public.js'; import { createTestRepo } from './helpers/test-repo.js'; +import { createFullDataRepo } from './helpers/test-full-repo.js'; + +const exec = promisify(execFile); const now = '2026-05-16T00:00:00Z'; const uuid = (n: number) => `01951a3c-0000-7000-8000-${String(n).padStart(12, '0')}`; @@ -99,6 +106,59 @@ describe('public store (gitsheets)', () => { await cleanup(); } }); + + it('drops null/undefined-valued keys before writing (gitsheets 2.x marshal contract)', async () => { + // gitsheets 2.x (Rust core) throws when asked to marshal a null- or + // undefined-valued field to TOML; 1.4.1 silently dropped such keys. Our + // Zod schemas use `.nullable().optional()` and write services normalize + // cleared fields to `?? null`, so openPublicStore's validator wrapper must + // strip those keys — keeping the on-disk form byte-identical to 1.4.1 + // (an absent optional field is simply an absent TOML key). See + // apps/api/src/store/public.ts → stripNullish / asValidator. + const repo = await createFullDataRepo(); + try { + const { store } = await openPublicStore(repo.path); + + await store.transact( + { message: 'test: person with nullish fields', author: { name: 'test', email: 'test@cfp.test' } }, + async (tx) => { + await tx.people.upsert( + PersonSchema.parse({ + id: uuid(70), + slug: 'nullish-person', + fullName: 'Nullish Person', + accountLevel: 'user', + legacyId: 31618, // integer w/o underscore (2.x re-baseline) + bio: null, // explicit null — must be dropped, not written + avatarKey: null, + deletedAt: null, + createdAt: now, + updatedAt: now, + }), + ); + }, + ); + + const { stdout: toml } = await exec( + 'git', + ['show', 'HEAD:people/nullish-person.toml'], + { cwd: repo.path }, + ); + + // Present fields survive. + expect(toml).toContain('slug = "nullish-person"'); + expect(toml).toContain('fullName = "Nullish Person"'); + // Integer re-baseline: no underscore separator under the Rust core. + expect(toml).toContain('legacyId = 31618'); + // Null-valued keys are absent from disk (never serialized as `null`). + expect(toml).not.toMatch(/^bio\s*=/m); + expect(toml).not.toMatch(/^avatarKey\s*=/m); + expect(toml).not.toMatch(/^deletedAt\s*=/m); + expect(toml).not.toContain('null'); + } finally { + await repo.cleanup(); + } + }); }); // ------------------------------------------------------------------------- From 841bd4a6d81d4d3715f5bec031b2c680eae1f359 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 4 Jul 2026 13:24:05 -0400 Subject: [PATCH 05/64] docs(plans): note gitsheets 2.x null-marshal break + check off validation Record the undocumented null/undefined marshal-contract change found during the bump (Rust core throws where 1.4.1 dropped the key) and the stripNullish fix at the write boundary. Check off the validation checklist; note no test needed a re-baseline update. Co-Authored-By: Claude Sonnet 4.6 --- plans/gitsheets-2x-bump.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/plans/gitsheets-2x-bump.md b/plans/gitsheets-2x-bump.md index 5e9daa2..73f1585 100644 --- a/plans/gitsheets-2x-bump.md +++ b/plans/gitsheets-2x-bump.md @@ -27,8 +27,22 @@ workarounds are therefore **ported/verified, not deleted**. our code compiles as-is. - **`hologit` is dropped as a gitsheets dependency** (2.x deps are `@gitsheets/core-napi` + csv/rfc6902/sort-keys/yargs — no hologit). This is - the **one real breaking change for us**: our avatar-blob-write path imports + **one real breaking change for us**: our avatar-blob-write path imports `BlobObject` from `hologit` and uses `publicRepo.hologitRepo`. +- **Null/undefined marshal contract changed (undocumented — found during this + bump).** The Rust core *throws* when asked to marshal a `null`- or + `undefined`-valued field to TOML (`cannot marshal JS value of type + Null/Undefined to a TOML value`). 1.4.1 (`@iarna/toml`) silently dropped such + keys. Our Zod schemas use `.nullable().optional()` and write services + normalize cleared fields to `?? null`, so every write of a record with a + cleared optional field threw a 500 under 2.x (16 test failures across + write-api / people-lifecycle / import-laddr). **Fix:** `openPublicStore` now + wraps each Standard Schema validator to strip null/undefined keys before the + record reaches the core marshaller (`apps/api/src/store/public.ts` → + `stripNullish` / `asValidator`). This is byte-identical to 1.4.1's on-disk + form (an absent optional field = an absent TOML key; verified against the + `published` snapshot). This is NOT one of the two documented re-baselines + below. - **Two deliberate one-time byte re-baselines** (data-level, lossless — values unchanged, only formatting): 1. **Canonical TOML**: Rust `toml`/`toml_edit` drops integer underscores @@ -105,9 +119,10 @@ API. Sites: ## Validation checklist -- [ ] deps bumped; hologit gone from lock -- [ ] blob-write path migrated off hologit; casts removed -- [ ] workarounds verified (compile + covered by tests) -- [ ] type-check + lint clean -- [ ] full api suite green (re-baseline test updates noted) -- [ ] byte-parity on `published` = lossless (only documented re-baselines) +- [x] deps bumped; hologit gone from lock +- [x] blob-write path migrated off hologit; casts removed +- [x] workarounds verified (compile + covered by tests) +- [x] null/undefined marshal contract handled (`stripNullish` at write boundary) +- [x] type-check + lint clean +- [x] full api suite green (no re-baseline test updates were needed — see below) +- [x] byte-parity on `published` = lossless (only documented re-baselines) From 5e5e219af8a19b806cd10895fefd8efec0de72cb Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 4 Jul 2026 13:47:13 -0400 Subject: [PATCH 06/64] test(store): fix null-marshal assertion false-matching fixture name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `not.toContain('null')` check tripped on the fixture's own "Nullish Person" / "nullish-person" strings. The three field-absence regexes above already verify null-valued keys aren't written; replace the broad substring check with `not.toMatch(/=\s*null\b/)` — no field is assigned a bare null value. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/tests/store.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/tests/store.test.ts b/apps/api/tests/store.test.ts index 7e4e150..49423a8 100644 --- a/apps/api/tests/store.test.ts +++ b/apps/api/tests/store.test.ts @@ -154,7 +154,9 @@ describe('public store (gitsheets)', () => { expect(toml).not.toMatch(/^bio\s*=/m); expect(toml).not.toMatch(/^avatarKey\s*=/m); expect(toml).not.toMatch(/^deletedAt\s*=/m); - expect(toml).not.toContain('null'); + // No field is assigned a bare `null` value. (Substring 'null' on its own + // would false-match the fixture's "Nullish Person" / "nullish-person".) + expect(toml).not.toMatch(/=\s*null\b/); } finally { await repo.cleanup(); } From 89f21e820312397bb94ecad2efffd4dc5148a656 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 8 Sep 2026 19:08:53 -0400 Subject: [PATCH 07/64] build(api): swap resend for postmark Postmark is what the legacy site already sends through, with the codeforphilly.org sender domain verified there; Resend was an unreviewed choice. Generated by: npm install -w apps/api postmark npm uninstall -w apps/api resend Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- apps/api/package.json | 2 +- package-lock.json | 60 ++++++++----------------------------------- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index a7ff31e..85d4050 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -37,7 +37,7 @@ "fastify": "^5.8.5", "gitsheets": "^2.2.0", "jose": "^6.2.3", - "resend": "^6.12.4", + "postmark": "^5.1.0", "samlify": "^2.13.0", "sharp": "^0.34.5", "uuidv7": "^1.2.1", diff --git a/package-lock.json b/package-lock.json index aa3e9f8..6181638 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "fastify": "^5.8.5", "gitsheets": "^2.2.0", "jose": "^6.2.3", - "resend": "^6.12.4", + "postmark": "^5.1.0", "samlify": "^2.13.0", "sharp": "^0.34.5", "uuidv7": "^1.2.1", @@ -5415,12 +5415,6 @@ "node": ">=14.0.0" } }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -8337,12 +8331,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -11904,12 +11892,6 @@ "node": ">=16.20.0" } }, - "node_modules/postal-mime": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", - "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", - "license": "MIT-0" - }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -11951,6 +11933,15 @@ "node": ">=4" } }, + "node_modules/postmark": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-5.1.0.tgz", + "integrity": "sha512-8lj2Fu94pL3fzx9Sy599opqpK4Z/0/1xCHZS8tbGBnqSS5rAcXtcHQ+TI0Jc4VwlBWma1Ug0Qm98FEe+v94BIQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -12618,27 +12609,6 @@ "node": ">=0.10.0" } }, - "node_modules/resend": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", - "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", - "license": "MIT", - "dependencies": { - "postal-mime": "2.7.4", - "standardwebhooks": "1.0.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@react-email/render": "*" - }, - "peerDependenciesMeta": { - "@react-email/render": { - "optional": true - } - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -13380,16 +13350,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", From 1c609aa15666fdbd33130a01a67edbe02a7b3583 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 8 Sep 2026 19:09:48 -0400 Subject: [PATCH 08/64] docs(specs): move the email transport from Resend to Postmark Postmark is the provider the legacy site already sends through, and the codeforphilly.org sender domain is verified there; Resend was a provider choice nobody reviewed. The notifier contract itself (LoggingNotifier fallback when unconfigured, log-not-throw delivery, CFP_NOTIFICATION_FROM / CFP_SITE_HOST) is unchanged. Env surface becomes POSTMARK_SERVER_TOKEN (secret, optional) plus POSTMARK_MESSAGE_STREAM (ConfigMap, default `outbound`). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- deploy/kustomize/base/configmap.yaml | 5 ++-- docs/operations/cutover-announcement.md | 2 +- docs/operations/cutover.md | 6 ++--- docs/operations/deploy.md | 5 ++-- docs/operations/secrets.md | 36 ++++++++++++++----------- specs/architecture.md | 7 ++--- specs/deferred.md | 4 +-- 7 files changed, 37 insertions(+), 28 deletions(-) diff --git a/deploy/kustomize/base/configmap.yaml b/deploy/kustomize/base/configmap.yaml index aafce58..512a734 100644 --- a/deploy/kustomize/base/configmap.yaml +++ b/deploy/kustomize/base/configmap.yaml @@ -11,8 +11,9 @@ data: # Sandbox overlay overrides this to next-v2.codeforphilly.org. CFP_SITE_HOST: "codeforphilly.org" # From-address for outbound notifications. Sender domain (codeforphilly.org) - # must be verified in Resend with SPF + DKIM + DMARC before flipping this - # on in production. See plans/notifier-email.md and docs/operations/secrets.md. + # must be a verified Postmark sender signature (SPF + DKIM + Return-Path) + # before flipping POSTMARK_SERVER_TOKEN on in production. See + # plans/postmark-notifier.md and docs/operations/secrets.md. CFP_NOTIFICATION_FROM: "Code for Philly " GIT_AUTHOR_EMAIL: "api@codeforphilly.org" GIT_AUTHOR_NAME: "CodeForPhilly API" diff --git a/docs/operations/cutover-announcement.md b/docs/operations/cutover-announcement.md index e3183b4..6fd73cc 100644 --- a/docs/operations/cutover-announcement.md +++ b/docs/operations/cutover-announcement.md @@ -34,7 +34,7 @@ What we need from you BEFORE cutover: If you have questions: drop them in this thread or DM @{{ cutover_lead_slack }}. ``` -### Email (Resend, to all members) +### Email (Postmark, to all members) Subject: `codeforphilly.org is migrating on {{ cutover_date_long }}` diff --git a/docs/operations/cutover.md b/docs/operations/cutover.md index 4fa9095..ecd5bf7 100644 --- a/docs/operations/cutover.md +++ b/docs/operations/cutover.md @@ -37,7 +37,7 @@ should be explicit in the cutover Slack post. ## T-7 days: announce + freeze 1. Post the cutover announcement from [cutover-announcement.md](cutover-announcement.md) - to `#announcements` and email all members via Resend. + to `#announcements` and email all members via Postmark. 2. Lower DNS TTL on `codeforphilly.org` to 60s. Verify with `dig`. 3. **Freeze legacy writes.** Either put a banner on the legacy site asking members to hold off on edits, or flip a feature flag making it read-only. @@ -261,10 +261,10 @@ still unclaimed: 3. Send: ```bash - RESEND_API_KEY=... npm run -w apps/api script:cutover-mailout -- --send + POSTMARK_SERVER_TOKEN=... npm run -w apps/api script:cutover-mailout -- --send ``` -4. Monitor Resend dashboard for bounces. Hard bounces are expected — +4. Monitor the Postmark activity stream for bounces. Hard bounces are expected — defunct email providers are exactly why these accounts are unclaimed. diff --git a/docs/operations/deploy.md b/docs/operations/deploy.md index 5389169..9989b5a 100644 --- a/docs/operations/deploy.md +++ b/docs/operations/deploy.md @@ -226,8 +226,9 @@ comments. Production pod gets these mounted: | `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"`. | -| `RESEND_API_KEY` | **Secret** | Resend HTTPS API key for outbound notifications. When unset, the help-wanted notifier falls back to a no-op LoggingNotifier — convenient for dev + tests but means no real emails go out. | -| `CFP_NOTIFICATION_FROM` | ConfigMap | RFC 5322 sender address for outbound notifications (default `"Code for Philly "`). Sender domain must be verified in Resend with SPF/DKIM/DMARC before flipping `RESEND_API_KEY` on. | +| `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. | | `STORAGE_BACKEND` | ConfigMap | `s3` (prod) / `filesystem` (sandbox) | | `CFP_PRIVATE_STORAGE_PATH` | ConfigMap | `/app/private-storage` (when filesystem) | | `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` | ConfigMap | Bucket addressing | diff --git a/docs/operations/secrets.md b/docs/operations/secrets.md index 0b5b204..2e86f0e 100644 --- a/docs/operations/secrets.md +++ b/docs/operations/secrets.md @@ -118,22 +118,28 @@ integration ([specs/api/saml.md](../../specs/api/saml.md)). - **Cadence:** every 36 months (cert expiry), plus immediately on suspected leak. -### `RESEND_API_KEY` - -API key for the [Resend](https://resend.com) HTTPS email API. Drives the -help-wanted email notifier. When unset, the API falls back to a no-op -`LoggingNotifier` — convenient for local dev but means real users get no -outbound mail in production. - -- **Generate:** Resend dashboard → API Keys → Create API key. Scope to - send-only on the `codeforphilly.org` sender domain. -- **Pre-flight:** the sender domain (`codeforphilly.org`) must be verified - in Resend with SPF + DKIM + DMARC records before flipping this on. - Unverified domains get hard-bounced or spam-filtered immediately. +### `POSTMARK_SERVER_TOKEN` + +Server API token for the [Postmark](https://postmarkapp.com) HTTPS email +API. Drives the email notifier (help-wanted, welcome, password-reset). +When unset, the API falls back to a no-op `LoggingNotifier` — convenient +for local dev but means real users get no outbound mail in production. + +- **Generate:** Postmark → the Code for Philly account (the same one the + legacy site sends through) → Servers → pick or create a server for this + app → API Tokens → Create token. One server per environment (sandbox + vs. prod) keeps activity streams and bounces separate. +- **Pre-flight:** the sender domain (`codeforphilly.org`) is already + verified (SPF + DKIM + Return-Path) in the Postmark account from the + legacy site; confirm it still shows verified under Sender Signatures + before flipping this on. The optional `POSTMARK_MESSAGE_STREAM` + ConfigMap value (default `outbound`) must name a transactional stream + that exists on the chosen server. - **Rotation impact:** none in-flight (no in-flight email state on our - end); next outbound mail uses the new key. -- **Rotation procedure:** create new key in Resend → update sealed-secret - → `kubectl rollout restart` → revoke the old key in Resend. + end); next outbound mail uses the new token. +- **Rotation procedure:** create new token in Postmark → update + sealed-secret → `kubectl rollout restart` → delete the old token in + Postmark. - **Cadence:** every 12 months, plus immediately on suspected leak. ### Data-repo deploy key diff --git a/specs/architecture.md b/specs/architecture.md index 5ff06ee..010a0c8 100644 --- a/specs/architecture.md +++ b/specs/architecture.md @@ -25,7 +25,7 @@ Out of scope for v1: see [deferred.md](deferred.md). | File uploads (avatars, buzz images) | **gitsheets attachments** | Binary blobs stored alongside their record via gitsheets' `setAttachment` API; served via streaming `GET /api/attachments/`. | | Background jobs | **In-process timers + an in-memory queue** | At single-replica civic scale we don't need Redis/BullMQ for fan-out. Image thumbnailing, scheduled rollups, and async git pushes run in the same process. | | Logging | **pino** (Fastify default) | Pretty in dev, JSON in prod. | -| Email | **Resend** (transactional) | For notifications like "help wanted interest expressed" and newsletter delivery (when that ships). Service account, not per-user OAuth. | +| Email | **Postmark** (transactional) | For notifications like "help wanted interest expressed" and newsletter delivery (when that ships). Server token, not per-user OAuth. Postmark is what the legacy site already sends through, so the `codeforphilly.org` sender domain is already verified there. | ### What we deliberately *don't* use @@ -178,8 +178,9 @@ Runtime configuration (sealed-secrets in our cluster): | `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. | -| `RESEND_API_KEY` | Optional. When set, mutates the notifier from the no-op `LoggingNotifier` to the live `EmailNotifier` (Resend SDK). | -| `CFP_NOTIFICATION_FROM` | Required when `RESEND_API_KEY` is set; the `From:` address on outbound mail. | +| `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_DATA_RELOAD_SECRET` | Bearer token gating `POST /api/_internal/reload-data` — the hot-reload webhook. Optional in dev; required in prod. | diff --git a/specs/deferred.md b/specs/deferred.md index 80e24ad..e4d41f8 100644 --- a/specs/deferred.md +++ b/specs/deferred.md @@ -99,9 +99,9 @@ When a deferred item is promoted, move it from this file into the relevant spec, ### Newsletter sending pipeline -- **What:** A flow that takes a composed newsletter (subject, markdown body) and sends it to all opted-in subscribers via Resend (or whatever transactional-email provider we end up on). +- **What:** A flow that takes a composed newsletter (subject, markdown body) and sends it to all opted-in subscribers via Postmark (the transactional provider the notifier already uses — see [architecture.md](architecture.md)). - **Why deferred:** v1 stores subscription state in `PrivateProfile.newsletter` (see [data-model.md](data-model.md#privateprofile-private) and [behaviors/private-storage.md](behaviors/private-storage.md)) so staff can CSV-export the active subscriber list to whatever sending tool they currently use (MailChimp web UI, etc.). The send-from-the-site pipeline is a follow-up spec when there's an active newsletter author committed to using it. -- **When promoted:** Spec a `/api/newsletter/send` endpoint with admin auth, a Resend-backed worker, unsubscribe-link generation off the existing `PrivateProfile.newsletter.unsubscribeToken`, delivery + bounce tracking. +- **When promoted:** Spec a `/api/newsletter/send` endpoint with admin auth, a Postmark-backed worker, unsubscribe-link generation off the existing `PrivateProfile.newsletter.unsubscribeToken`, delivery + bounce tracking. ### `connectors/` ingestion From 6b8d01f554054105dedf48a29335bd49f9f5e436 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 8 Sep 2026 19:30:38 -0400 Subject: [PATCH 09/64] feat(api): send notifier email through Postmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Resend-backed EmailNotifier with a provider-neutral seam. EmailNotifier now depends on a one-method `EmailTransport` (`transport.ts`), and `PostmarkTransport` is the only file that knows a vendor: it maps our from/to/subject/text/html onto Postmark's PascalCase Message and tags the configured MessageStream. The notifier contract is unchanged — LoggingNotifier fallback when unconfigured, log-and-`delivered:false` on any failure, same templates, same CFP_NOTIFICATION_FROM / CFP_SITE_HOST inputs. Postmark's SDK throws on every non-2xx rather than resolving with an `{ error }` envelope, so the notifier's four copy-pasted send blocks collapse into one `#deliver` with a single catch; the logged `err` carries Postmark's code/statusCode so operators can still tell a rejected sender from a network blip. Env: `RESEND_API_KEY` -> `POSTMARK_SERVER_TOKEN` (optional), plus `POSTMARK_MESSAGE_STREAM` (default `outbound`). The cutover-mailout script drops its hand-rolled fetch call and reuses PostmarkTransport. Tests: the notifier suite stubs the transport seam directly; a new postmark-transport suite covers the field mapping with a stub and the real ServerClient against an MSW intercept of POST /email, so an SDK wire-format change surfaces in CI instead of production. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr --- .env.example | 17 +- apps/api/scripts/cutover-mailout.ts | 50 ++--- apps/api/src/env.ts | 19 +- apps/api/src/notify/email-notifier.ts | 212 +++++++--------------- apps/api/src/notify/index.ts | 5 +- apps/api/src/notify/postmark-transport.ts | 48 +++++ apps/api/src/notify/transport.ts | 25 +++ apps/api/src/plugins/services.ts | 15 +- apps/api/src/routes/auth.ts | 2 +- apps/api/tests/cutover-mailout.test.ts | 4 +- apps/api/tests/email-notifier.test.ts | 43 +++-- apps/api/tests/github-oauth.test.ts | 2 +- apps/api/tests/helpers/mocks.ts | 33 ++-- apps/api/tests/postmark-transport.test.ts | 89 +++++++++ 14 files changed, 332 insertions(+), 232 deletions(-) create mode 100644 apps/api/src/notify/postmark-transport.ts create mode 100644 apps/api/src/notify/transport.ts create mode 100644 apps/api/tests/postmark-transport.test.ts diff --git a/.env.example b/.env.example index 4c1e940..240dcb5 100644 --- a/.env.example +++ b/.env.example @@ -92,14 +92,19 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars # CFP_SITE_HOST=codeforphilly.org # --------------------------------------------------------------------------- -# Outbound notifications (Resend) +# Outbound notifications (Postmark) # --------------------------------------------------------------------------- -# Resend API key for the help-wanted email notifier. When unset, the -# notifier falls back to a no-op LoggingNotifier so dev + tests work -# without an account. See plans/notifier-email.md. -# RESEND_API_KEY=re_… +# Postmark server token for the email notifier (help-wanted, welcome, +# password-reset). When unset, the notifier falls back to a no-op +# LoggingNotifier so dev + tests work without an account. See +# plans/postmark-notifier.md and docs/operations/secrets.md. +# POSTMARK_SERVER_TOKEN=… + +# Postmark message stream to send on. Defaults to `outbound`, the +# transactional stream every Postmark server ships with. +# POSTMARK_MESSAGE_STREAM=outbound # From-address for outbound notifications. RFC 5322 form. -# Only used when RESEND_API_KEY is set. +# Only used when POSTMARK_SERVER_TOKEN is set. # CFP_NOTIFICATION_FROM="Code for Philly " diff --git a/apps/api/scripts/cutover-mailout.ts b/apps/api/scripts/cutover-mailout.ts index 93a72a8..032f674 100644 --- a/apps/api/scripts/cutover-mailout.ts +++ b/apps/api/scripts/cutover-mailout.ts @@ -6,7 +6,7 @@ * them to sign in and claim their account. Run manually at T+90 per * specs/behaviors/account-migration.md#cutover-window-policy. * - * --dry-run prints the would-be send list and exits — no Resend calls, no + * --dry-run prints the would-be send list and exits — no Postmark calls, no * disk writes. The CI test exercises only --dry-run. * * Usage: @@ -14,7 +14,8 @@ * npm run -w apps/api script:cutover-mailout -- --send --from=hello@codeforphilly.org * * Env: - * RESEND_API_KEY — required for actual sends (otherwise --send refuses) + * POSTMARK_SERVER_TOKEN — required for actual sends (otherwise --send refuses) + * POSTMARK_MESSAGE_STREAM — optional; defaults to `outbound` * CFP_PUBLIC_URL — base URL used in the email body (defaults to * https://codeforphilly.org) * CFP_DATA_REPO_PATH + STORAGE_BACKEND + bucket envs — same shape as the API @@ -22,6 +23,9 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { ServerClient } from 'postmark'; + +import { PostmarkTransport } from '../src/notify/postmark-transport.js'; import { openPublicStore, type PublicStore } from '../src/store/public.js'; import { FilesystemPrivateStore, @@ -195,7 +199,7 @@ export async function runMailout(opts: MailoutOptions): Promise { } // --------------------------------------------------------------------------- -// Env wiring + Resend send +// Env wiring + Postmark send // --------------------------------------------------------------------------- function requireEnv(name: string): string { @@ -220,33 +224,19 @@ function buildPrivateStore(): PrivateStore { }); } -/** Resend HTTP send. Fetch-based to avoid adding a new dep at this stage. */ -async function resendSend(input: { - to: string; - from: string; - subject: string; - html: string; - text: string; -}): Promise { - const apiKey = requireEnv('RESEND_API_KEY'); - const res = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'authorization': `Bearer ${apiKey}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - from: input.from, - to: input.to, - subject: input.subject, - html: input.html, - text: input.text, - }), +/** + * Postmark send via the same transport the API's notifier uses. The SDK + * throws on any non-2xx, which runMailout() records per-recipient in + * `failed` rather than aborting the run. + */ +function buildPostmarkSend(): NonNullable { + const transport = new PostmarkTransport({ + client: new ServerClient(requireEnv('POSTMARK_SERVER_TOKEN')), + messageStream: process.env['POSTMARK_MESSAGE_STREAM'] || undefined, }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Resend ${res.status}: ${body.slice(0, 200)}`); - } + return async (input) => { + await transport.send(input); + }; } // --------------------------------------------------------------------------- @@ -299,7 +289,7 @@ async function main(): Promise { mode: args.dryRun ? 'dry-run' : 'send', from: args.from, publicUrl: args.publicUrl ?? process.env['CFP_PUBLIC_URL'], - send: args.send ? resendSend : undefined, + send: args.send ? buildPostmarkSend() : undefined, }); process.stderr.write( diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b171799..c460c84 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -68,15 +68,21 @@ export const EnvSchema = z.object({ */ CFP_SITE_HOST: z.string().default('codeforphilly.org'), /** - * Resend API key for the email notifier. When unset, the services plugin - * falls back to LoggingNotifier so dev + test runs don't need a real key. - * See plans/notifier-email.md. + * Postmark server token for the email notifier. When unset, the services + * plugin falls back to LoggingNotifier so dev + test runs don't need a + * real token. See plans/postmark-notifier.md. */ - RESEND_API_KEY: z.string().optional(), + POSTMARK_SERVER_TOKEN: z.string().optional(), + /** + * Postmark message stream outbound mail is sent on. `outbound` is the + * transactional default stream every Postmark server ships with. Only + * relevant when POSTMARK_SERVER_TOKEN is set. + */ + POSTMARK_MESSAGE_STREAM: z.string().default('outbound'), /** * From-address for outbound notifications. RFC 5322 form * (e.g. `"Code for Philly "`). Only - * relevant when RESEND_API_KEY is set. + * relevant when POSTMARK_SERVER_TOKEN is set. */ CFP_NOTIFICATION_FROM: z .string() @@ -118,7 +124,8 @@ export const envJsonSchema = { SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' }, CFP_WEB_DIST_PATH: { type: 'string' }, CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' }, - RESEND_API_KEY: { type: 'string' }, + POSTMARK_SERVER_TOKEN: { type: 'string' }, + POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' }, CFP_NOTIFICATION_FROM: { type: 'string', default: 'Code for Philly ', diff --git a/apps/api/src/notify/email-notifier.ts b/apps/api/src/notify/email-notifier.ts index 40144b1..0b6cdcd 100644 --- a/apps/api/src/notify/email-notifier.ts +++ b/apps/api/src/notify/email-notifier.ts @@ -1,18 +1,18 @@ /** - * EmailNotifier — Resend-backed implementation of the Notifier interface. + * EmailNotifier — transport-backed implementation of the Notifier interface. * - * Sends help-wanted notifications via the Resend HTTPS API. Delivery - * failures are logged but never thrown — per - * `specs/api/projects-help-wanted.md`, the express-interest endpoint - * returns 202 to the caller regardless of downstream notification - * outcome. + * Renders each notification through the templates module and hands the + * result to an `EmailTransport` (Postmark in production — see + * `postmark-transport.ts`). Delivery failures are logged but never thrown — + * per `specs/api/projects-help-wanted.md`, the express-interest endpoint + * returns 202 to the caller regardless of downstream notification outcome, + * and the auth routes fire-and-forget for the same reason. * * Slack DM is deliberately out of scope here (tracked at #95); this is * the email-only first cut. The Notifier interface still accepts * `maintainerSlackHandle` so the data flow is ready when Slack lands. */ import type { FastifyBaseLogger } from 'fastify'; -import type { Resend } from 'resend'; import type { HelpWantedFillNotification, @@ -27,10 +27,18 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from './templates.js'; +import type { EmailTransport } from './transport.js'; + +/** Common shape of every template renderer's output. */ +interface RenderedEmail { + readonly subject: string; + readonly text: string; + readonly html: string; +} export interface EmailNotifierOptions { - /** Resend client (constructed at boot with the API key from env). */ - readonly resend: Resend; + /** Provider adapter (constructed at boot from env; a stub in tests). */ + readonly transport: EmailTransport; /** Sender address — RFC 5322 form, e.g. `"Code for Philly "`. */ readonly fromAddress: string; /** Public site host (no scheme), used to construct absolute URLs in email bodies. */ @@ -40,13 +48,13 @@ export interface EmailNotifierOptions { } export class EmailNotifier implements Notifier { - readonly #resend: Resend; + readonly #transport: EmailTransport; readonly #from: string; readonly #siteHost: string; readonly #log: FastifyBaseLogger; constructor(opts: EmailNotifierOptions) { - this.#resend = opts.resend; + this.#transport = opts.transport; this.#from = opts.fromAddress; this.#siteHost = opts.siteHost; this.#log = opts.logger; @@ -55,174 +63,82 @@ export class EmailNotifier implements Notifier { async notifyHelpWantedInterest( n: HelpWantedInterestNotification, ): Promise<{ delivered: boolean }> { + const ctx = { kind: 'help-wanted.interest', projectSlug: n.projectSlug, roleId: n.roleId }; if (!n.maintainerEmail) { - this.#log.warn( - { kind: 'help-wanted.interest', projectSlug: n.projectSlug, roleId: n.roleId }, - 'help-wanted interest: no maintainer email; skipped', - ); - return { delivered: false }; - } - const tpl = renderInterestEmail(n, this.#siteHost); - try { - const result = await this.#resend.emails.send({ - from: this.#from, - to: n.maintainerEmail, - subject: tpl.subject, - text: tpl.text, - html: tpl.html, - }); - if (result.error) { - this.#log.error( - { - kind: 'help-wanted.interest', - err: result.error, - projectSlug: n.projectSlug, - roleId: n.roleId, - }, - 'help-wanted interest: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { - kind: 'help-wanted.interest', - projectSlug: n.projectSlug, - roleId: n.roleId, - resendId: result.data?.id, - }, - 'help-wanted interest: email queued for delivery', - ); - return { delivered: true }; - } catch (err) { - this.#log.error( - { - kind: 'help-wanted.interest', - err, - projectSlug: n.projectSlug, - roleId: n.roleId, - }, - 'help-wanted interest: email send threw', - ); + this.#log.warn(ctx, 'help-wanted interest: no maintainer email; skipped'); return { delivered: false }; } + return this.#deliver( + 'help-wanted interest', + ctx, + n.maintainerEmail, + renderInterestEmail(n, this.#siteHost), + ); } async notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }> { + const ctx = { kind: 'auth.welcome', slug: n.slug }; if (!n.email) { - this.#log.warn( - { kind: 'auth.welcome', slug: n.slug }, - 'welcome: no email address; skipped', - ); - return { delivered: false }; - } - const tpl = renderWelcomeEmail(n, this.#siteHost); - try { - const result = await this.#resend.emails.send({ - from: this.#from, - to: n.email, - subject: tpl.subject, - text: tpl.text, - html: tpl.html, - }); - if (result.error) { - this.#log.error( - { kind: 'auth.welcome', err: result.error, slug: n.slug }, - 'welcome: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { kind: 'auth.welcome', slug: n.slug, resendId: result.data?.id }, - 'welcome: email queued for delivery', - ); - return { delivered: true }; - } catch (err) { - this.#log.error( - { kind: 'auth.welcome', err, slug: n.slug }, - 'welcome: email send threw', - ); + this.#log.warn(ctx, 'welcome: no email address; skipped'); return { delivered: false }; } + return this.#deliver('welcome', ctx, n.email, renderWelcomeEmail(n, this.#siteHost)); } async notifyPasswordReset(n: PasswordResetNotification): Promise<{ delivered: boolean }> { + const ctx = { kind: 'auth.password-reset', slug: n.slug }; if (!n.email) { - this.#log.warn( - { kind: 'auth.password-reset', slug: n.slug }, - 'password-reset: no email address; skipped', - ); - return { delivered: false }; - } - const tpl = renderPasswordResetEmail(n, this.#siteHost); - try { - const result = await this.#resend.emails.send({ - from: this.#from, - to: n.email, - subject: tpl.subject, - text: tpl.text, - html: tpl.html, - }); - if (result.error) { - this.#log.error( - { kind: 'auth.password-reset', err: result.error, slug: n.slug }, - 'password-reset: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { kind: 'auth.password-reset', slug: n.slug, resendId: result.data?.id }, - 'password-reset: email queued for delivery', - ); - return { delivered: true }; - } catch (err) { - this.#log.error( - { kind: 'auth.password-reset', err, slug: n.slug }, - 'password-reset: email send threw', - ); + this.#log.warn(ctx, 'password-reset: no email address; skipped'); return { delivered: false }; } + return this.#deliver( + 'password-reset', + ctx, + n.email, + renderPasswordResetEmail(n, this.#siteHost), + ); } async notifyHelpWantedFilled( n: HelpWantedFillNotification, ): Promise<{ delivered: boolean }> { + const ctx = { kind: 'help-wanted.filled', projectTitle: n.projectTitle }; if (!n.maintainerEmail) { - this.#log.warn( - { kind: 'help-wanted.filled', projectTitle: n.projectTitle }, - 'help-wanted fill: no maintainer email; skipped', - ); + this.#log.warn(ctx, 'help-wanted fill: no maintainer email; skipped'); return { delivered: false }; } - const tpl = renderFilledEmail(n, this.#siteHost); + return this.#deliver( + 'help-wanted fill', + ctx, + n.maintainerEmail, + renderFilledEmail(n, this.#siteHost), + ); + } + + /** + * Shared send path. The transport's only failure shape is a throw (the + * Postmark SDK raises on every non-2xx), so one catch covers network + * blips and provider rejections alike; the `err` field carries the + * provider's code/status for operators to tell them apart. + */ + async #deliver( + label: string, + ctx: Record, + to: string, + tpl: RenderedEmail, + ): Promise<{ delivered: boolean }> { try { - const result = await this.#resend.emails.send({ + const { messageId } = await this.#transport.send({ from: this.#from, - to: n.maintainerEmail, + to, subject: tpl.subject, text: tpl.text, html: tpl.html, }); - if (result.error) { - this.#log.error( - { kind: 'help-wanted.filled', err: result.error, projectTitle: n.projectTitle }, - 'help-wanted fill: Resend reported delivery failure', - ); - return { delivered: false }; - } - this.#log.info( - { - kind: 'help-wanted.filled', - projectTitle: n.projectTitle, - resendId: result.data?.id, - }, - 'help-wanted fill: email queued for delivery', - ); + this.#log.info({ ...ctx, messageId }, `${label}: email queued for delivery`); return { delivered: true }; } catch (err) { - this.#log.error( - { kind: 'help-wanted.filled', err, projectTitle: n.projectTitle }, - 'help-wanted fill: email send threw', - ); + this.#log.error({ ...ctx, err }, `${label}: email send failed`); return { delivered: false }; } } diff --git a/apps/api/src/notify/index.ts b/apps/api/src/notify/index.ts index 2d98fcc..85035d7 100644 --- a/apps/api/src/notify/index.ts +++ b/apps/api/src/notify/index.ts @@ -5,7 +5,7 @@ * Slack integration exists. Failures are logged but never fail the request — * the spec says express-interest returns 202 to the caller regardless. * - * The Resend / email transport is also stubbed; this module exists so the + * The email transport is also stubbed; this module exists so the * surface is in place for write-api to call and for tests to spy on. */ import type { FastifyBaseLogger } from 'fastify'; @@ -66,7 +66,8 @@ export interface Notifier { /** * Default no-op notifier — logs the intent and returns delivered:true. - * Replace with a real notifier once the Resend / Slack transports land. + * Replaced at boot by EmailNotifier when POSTMARK_SERVER_TOKEN is set; the + * Slack transport is still to come (#95). */ export class LoggingNotifier implements Notifier { readonly #log: FastifyBaseLogger; diff --git a/apps/api/src/notify/postmark-transport.ts b/apps/api/src/notify/postmark-transport.ts new file mode 100644 index 0000000..29ed5de --- /dev/null +++ b/apps/api/src/notify/postmark-transport.ts @@ -0,0 +1,48 @@ +/** + * PostmarkTransport — EmailTransport backed by the official `postmark` SDK. + * + * Postmark is the provider the legacy site already sends through, so the + * `codeforphilly.org` sender signature is verified there (see + * specs/architecture.md and docs/operations/secrets.md). The SDK throws a + * `PostmarkError` subclass on every non-2xx response (bad token, inactive + * recipient, rate limit, 5xx) and resolves with `{ MessageID, ... }` on + * success — so this adapter needs no `{ error }` branch; a throw is the + * only failure shape and the notifier catches it. + */ +import type { Message, Models } from 'postmark'; + +import type { EmailTransport, OutboundEmail } from './transport.js'; + +/** The slice of `postmark.ServerClient` this adapter touches. */ +export interface PostmarkSender { + sendEmail(email: Message): Promise; +} + +export interface PostmarkTransportOptions { + /** `new ServerClient(POSTMARK_SERVER_TOKEN)` at boot; anything with `sendEmail` in tests. */ + readonly client: PostmarkSender; + /** Postmark message stream. Defaults to `outbound` (the transactional default stream). */ + readonly messageStream?: string; +} + +export class PostmarkTransport implements EmailTransport { + readonly #client: PostmarkSender; + readonly #messageStream: string; + + constructor(opts: PostmarkTransportOptions) { + this.#client = opts.client; + this.#messageStream = opts.messageStream ?? 'outbound'; + } + + async send(email: OutboundEmail): Promise<{ messageId: string }> { + const result = await this.#client.sendEmail({ + From: email.from, + To: email.to, + Subject: email.subject, + TextBody: email.text, + HtmlBody: email.html, + MessageStream: this.#messageStream, + }); + return { messageId: result.MessageID }; + } +} diff --git a/apps/api/src/notify/transport.ts b/apps/api/src/notify/transport.ts new file mode 100644 index 0000000..1dc9448 --- /dev/null +++ b/apps/api/src/notify/transport.ts @@ -0,0 +1,25 @@ +/** + * EmailTransport — the one-method seam between the Notifier and whichever + * provider actually delivers mail. + * + * `EmailNotifier` composes subject/text/html from templates and hands the + * result here. The transport either resolves with a provider message id + * or throws; it never swallows failures — the notifier owns the + * log-and-return-`delivered: false` contract. Keeping the seam this narrow + * means tests exercise the notifier with a `vi.fn()` and the provider + * adapter (`postmark-transport.ts`) is the only file that knows a vendor. + */ +export interface OutboundEmail { + /** RFC 5322 sender, e.g. `"Code for Philly "`. */ + readonly from: string; + /** Single recipient address. */ + readonly to: string; + readonly subject: string; + readonly text: string; + readonly html: string; +} + +export interface EmailTransport { + /** Deliver one message. Resolves with the provider's message id; throws on any failure. */ + send(email: OutboundEmail): Promise<{ messageId: string }>; +} diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 92bcbd5..f78d828 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -30,7 +30,8 @@ import { GitHubAccountService } from '../services/github-account.js'; import { AccountClaimService } from '../services/account-claim.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; import { EmailNotifier } from '../notify/email-notifier.js'; -import { Resend } from 'resend'; +import { PostmarkTransport } from '../notify/postmark-transport.js'; +import { ServerClient } from 'postmark'; declare module 'fastify' { interface FastifyInstance { @@ -67,13 +68,17 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { // (relevant in tests where multiple buildApp() runs share the module). invalidateFacets(); const fts = buildFtsEngine(state); - // Email notifier when RESEND_API_KEY is configured; otherwise fall back to - // the no-op LoggingNotifier so tests + dev runs work without a real key. + // Email notifier when POSTMARK_SERVER_TOKEN is configured; otherwise fall + // back to the no-op LoggingNotifier so tests + dev runs work without a + // real token. // Slack DM is deferred (#95) — when it lands it'll compose alongside email // here or via a CompoundNotifier wrapper. - const notifier: Notifier = fastify.config.RESEND_API_KEY + const notifier: Notifier = fastify.config.POSTMARK_SERVER_TOKEN ? new EmailNotifier({ - resend: new Resend(fastify.config.RESEND_API_KEY), + transport: new PostmarkTransport({ + client: new ServerClient(fastify.config.POSTMARK_SERVER_TOKEN), + messageStream: fastify.config.POSTMARK_MESSAGE_STREAM, + }), fromAddress: fastify.config.CFP_NOTIFICATION_FROM, siteHost: fastify.config.CFP_SITE_HOST, logger: fastify.log, diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index c249c0c..88d37d2 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -541,7 +541,7 @@ export async function authRoutes(fastify: FastifyInstance): Promise { }; await fastify.store.private.putPasswordToken(tokenRecord); - // Fire-and-forget — never block the response on Resend latency. + // Fire-and-forget — never block the response on email-provider latency. void fastify.notifier .notifyPasswordReset({ email: profile.email, diff --git a/apps/api/tests/cutover-mailout.test.ts b/apps/api/tests/cutover-mailout.test.ts index 2c57648..5983426 100644 --- a/apps/api/tests/cutover-mailout.test.ts +++ b/apps/api/tests/cutover-mailout.test.ts @@ -190,13 +190,13 @@ describe('cutover-mailout', () => { privateStore, mode: 'send', send: async () => { - throw new Error('Resend 429'); + throw new Error('Postmark 429'); }, now: NOW, }); expect(report.sent).toBe(0); expect(report.failed).toHaveLength(1); - expect(report.failed[0]?.error).toContain('Resend 429'); + expect(report.failed[0]?.error).toContain('Postmark 429'); } finally { await repo.cleanup(); await priv.cleanup(); diff --git a/apps/api/tests/email-notifier.test.ts b/apps/api/tests/email-notifier.test.ts index 69629a1..1e59f7a 100644 --- a/apps/api/tests/email-notifier.test.ts +++ b/apps/api/tests/email-notifier.test.ts @@ -1,9 +1,11 @@ /** - * Tests for the Resend-backed EmailNotifier (apps/api/src/notify/email-notifier.ts). + * Tests for the transport-backed EmailNotifier (apps/api/src/notify/email-notifier.ts). * - * Mocks the Resend SDK at the `emails.send` boundary — verifies that the + * Stubs the `EmailTransport` seam with a `vi.fn()` — verifies that the * notifier composes the right payload + handles delivery success/failure * per the spec (express-interest must return 202 to the caller regardless). + * The Postmark adapter behind that seam has its own test + * (postmark-transport.test.ts). * * Template renderers are also exercised here with snapshot-style asserts * on the interpolated fields, since they're pure functions with simple @@ -62,10 +64,9 @@ const baseWelcome: WelcomeNotification = { slug: 'new-user', }; -function makeNotifier(emails: { send: ReturnType }): EmailNotifier { +function makeNotifier(transport: { send: ReturnType }): EmailNotifier { return new EmailNotifier({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resend: { emails } as any, + transport, fromAddress: 'Code for Philly ', siteHost: 'codeforphilly.org', logger: noopLogger, @@ -120,8 +121,8 @@ describe('renderFilledEmail', () => { }); describe('EmailNotifier.notifyHelpWantedInterest', () => { - it('sends via Resend and returns delivered:true', async () => { - const send = vi.fn().mockResolvedValue({ data: { id: 'msg-123' }, error: null }); + it('sends via the transport and returns delivered:true', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'msg-123' }); const notifier = makeNotifier({ send }); const result = await notifier.notifyHelpWantedInterest(baseInterest); @@ -135,7 +136,7 @@ describe('EmailNotifier.notifyHelpWantedInterest', () => { expect(arg.html).toContain('Jane Doe'); }); - it('returns delivered:false when maintainerEmail is null (no Resend call)', async () => { + it('returns delivered:false when maintainerEmail is null (no transport call)', async () => { const send = vi.fn(); const notifier = makeNotifier({ send }); @@ -147,17 +148,20 @@ describe('EmailNotifier.notifyHelpWantedInterest', () => { expect(send).not.toHaveBeenCalled(); }); - it('returns delivered:false when Resend reports an error', async () => { + it('returns delivered:false when the provider rejects the send', async () => { + // Postmark surfaces API rejections (unverified sender, inactive + // recipient, bad token) as thrown errors carrying code + statusCode. const send = vi .fn() - .mockResolvedValue({ data: null, error: { message: 'Sender domain unverified' } }); + .mockRejectedValue(Object.assign(new Error('Sender signature not found'), { code: 400, statusCode: 422 })); const notifier = makeNotifier({ send }); const result = await notifier.notifyHelpWantedInterest(baseInterest); expect(result).toEqual({ delivered: false }); + expect(noopLogger.error).toHaveBeenCalled(); }); - it('returns delivered:false when the Resend SDK throws', async () => { + it('returns delivered:false when the transport throws', async () => { const send = vi.fn().mockRejectedValue(new Error('network blip')); const notifier = makeNotifier({ send }); @@ -196,8 +200,8 @@ describe('renderWelcomeEmail', () => { }); describe('EmailNotifier.notifyWelcomeOnSignup', () => { - it('sends via Resend and returns delivered:true', async () => { - const send = vi.fn().mockResolvedValue({ data: { id: 'msg-welcome' }, error: null }); + it('sends via the transport and returns delivered:true', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'msg-welcome' }); const notifier = makeNotifier({ send }); const result = await notifier.notifyWelcomeOnSignup(baseWelcome); @@ -210,11 +214,10 @@ describe('EmailNotifier.notifyWelcomeOnSignup', () => { expect(arg.html).toContain('New User'); }); - it('returns delivered:false when Resend reports an error', async () => { - const send = vi.fn().mockResolvedValue({ - data: null, - error: { message: 'Sender domain unverified' }, - }); + it('returns delivered:false when the provider rejects the send', async () => { + const send = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Inactive recipient'), { code: 406, statusCode: 422 })); const notifier = makeNotifier({ send }); const result = await notifier.notifyWelcomeOnSignup(baseWelcome); expect(result).toEqual({ delivered: false }); @@ -237,8 +240,8 @@ describe('EmailNotifier.notifyWelcomeOnSignup', () => { }); describe('EmailNotifier.notifyHelpWantedFilled', () => { - it('sends via Resend and returns delivered:true', async () => { - const send = vi.fn().mockResolvedValue({ data: { id: 'msg-456' }, error: null }); + it('sends via the transport and returns delivered:true', async () => { + const send = vi.fn().mockResolvedValue({ messageId: 'msg-456' }); const notifier = makeNotifier({ send }); const result = await notifier.notifyHelpWantedFilled(baseFill); diff --git a/apps/api/tests/github-oauth.test.ts b/apps/api/tests/github-oauth.test.ts index 34226a7..609ae14 100644 --- a/apps/api/tests/github-oauth.test.ts +++ b/apps/api/tests/github-oauth.test.ts @@ -519,7 +519,7 @@ describe('GET /api/auth/github/callback — fresh user outcome', () => { const ip = nextTestIp(); const flow = await startFlow(app, '/', ip); - // Spy on the boot-installed LoggingNotifier (no Resend in tests). + // Spy on the boot-installed LoggingNotifier (no Postmark in tests). // The notifier call is fire-and-forget — we await the OAuth response // first, then assert the spy. The notifier's spawn is synchronous up // to the await inside it, so it's guaranteed to have been called by diff --git a/apps/api/tests/helpers/mocks.ts b/apps/api/tests/helpers/mocks.ts index f7b4786..5dcad22 100644 --- a/apps/api/tests/helpers/mocks.ts +++ b/apps/api/tests/helpers/mocks.ts @@ -22,12 +22,14 @@ export interface GitHubEmail { /** * A captured outbound email send — inspectable in tests. */ +/** Postmark `POST /email` body — PascalCase fields as the API wants them. */ export interface CapturedEmail { - readonly to: string | string[]; - readonly from: string; - readonly subject: string; - readonly html?: string; - readonly text?: string; + readonly To: string; + readonly From: string; + readonly Subject: string; + readonly HtmlBody?: string; + readonly TextBody?: string; + readonly MessageStream?: string; } /** @@ -92,23 +94,32 @@ export function createGitHubMock(defaults?: { } /** - * No-op Resend mock. Intercepts POST /emails via MSW and collects sends - * into an in-memory array for inspection. Does not call the real Resend API. + * No-op Postmark mock. Intercepts POST /email via MSW and collects sends + * into an in-memory array for inspection. Does not call the real Postmark API. * * Usage: - * const { server, sentEmails } = createResendMock(); + * const { server, sentEmails } = createPostmarkMock(); * beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); * afterEach(() => { server.resetHandlers(); sentEmails.length = 0; }); * afterAll(() => server.close()); */ -export function createResendMock() { +export function createPostmarkMock() { const sentEmails: CapturedEmail[] = []; const server = setupServer( - http.post('https://api.resend.com/emails', async ({ request }) => { + http.post('https://api.postmarkapp.com/email', async ({ request }) => { const body = (await request.json()) as CapturedEmail; sentEmails.push(body); - return HttpResponse.json({ id: `mock-${Date.now()}` }, { status: 200 }); + return HttpResponse.json( + { + To: body.To, + SubmittedAt: new Date().toISOString(), + MessageID: `mock-${Date.now()}`, + ErrorCode: 0, + Message: 'OK', + }, + { status: 200 }, + ); }), ); diff --git a/apps/api/tests/postmark-transport.test.ts b/apps/api/tests/postmark-transport.test.ts new file mode 100644 index 0000000..a34c308 --- /dev/null +++ b/apps/api/tests/postmark-transport.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for PostmarkTransport (apps/api/src/notify/postmark-transport.ts). + * + * Two layers: + * - unit: a stub `sendEmail` proves the OutboundEmail → Postmark Message + * field mapping and that SDK errors propagate (the notifier owns + * catch-and-log, so the transport must not swallow them). + * - integration: the real `ServerClient` against an MSW intercept of + * `POST https://api.postmarkapp.com/email`, so a change in the SDK's + * wire format or auth header would surface here rather than in prod. + */ +import { ServerClient } from 'postmark'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { PostmarkTransport } from '../src/notify/postmark-transport.js'; +import { createPostmarkMock } from './helpers/mocks.js'; + +const email = { + from: 'Code for Philly ', + to: 'maintainer@example.com', + subject: 'Hello', + text: 'plain body', + html: '

html body

', +}; + +describe('PostmarkTransport (unit)', () => { + it('maps OutboundEmail onto the Postmark Message shape and returns MessageID', async () => { + const sendEmail = vi.fn().mockResolvedValue({ + To: email.to, + SubmittedAt: '2026-09-08T00:00:00Z', + MessageID: 'pm-123', + ErrorCode: 0, + Message: 'OK', + }); + const transport = new PostmarkTransport({ client: { sendEmail } }); + + const result = await transport.send(email); + expect(result).toEqual({ messageId: 'pm-123' }); + expect(sendEmail).toHaveBeenCalledTimes(1); + expect(sendEmail.mock.calls[0]![0]).toEqual({ + From: email.from, + To: email.to, + Subject: 'Hello', + TextBody: 'plain body', + HtmlBody: '

html body

', + MessageStream: 'outbound', + }); + }); + + it('honours an explicit messageStream', async () => { + const sendEmail = vi.fn().mockResolvedValue({ MessageID: 'pm-1', SubmittedAt: '', ErrorCode: 0, Message: 'OK' }); + const transport = new PostmarkTransport({ client: { sendEmail }, messageStream: 'notifications' }); + await transport.send(email); + expect(sendEmail.mock.calls[0]![0].MessageStream).toBe('notifications'); + }); + + it('propagates SDK errors untouched', async () => { + const boom = Object.assign(new Error('Inactive recipient'), { code: 406, statusCode: 422 }); + const sendEmail = vi.fn().mockRejectedValue(boom); + const transport = new PostmarkTransport({ client: { sendEmail } }); + await expect(transport.send(email)).rejects.toBe(boom); + }); +}); + +describe('PostmarkTransport (real ServerClient over MSW)', () => { + const mock = createPostmarkMock(); + beforeAll(() => mock.server.listen({ onUnhandledRequest: 'error' })); + afterEach(() => { + mock.server.resetHandlers(); + mock.sentEmails.length = 0; + }); + afterAll(() => mock.server.close()); + + it('POSTs the expected JSON body to /email', async () => { + const transport = new PostmarkTransport({ client: new ServerClient('test-server-token') }); + const result = await transport.send(email); + + expect(result.messageId).toMatch(/^mock-/); + expect(mock.sentEmails).toHaveLength(1); + expect(mock.sentEmails[0]).toEqual({ + From: email.from, + To: email.to, + Subject: 'Hello', + TextBody: 'plain body', + HtmlBody: '

html body

', + 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 ` {/* Mobile: auth + hamburger */}
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 ( - + {filtered.map((t: TagResponse, i) => ( +
  • selectOption(i)} + onMouseEnter={() => setActiveIndex(i)} + className={optionClass(i)} + > + {t.title}{' '} + + ({t.slug} · {t.projectCount} projects) +
  • ))} - {allowCreate && - query.trim() && - !exactMatch && - /^[a-z0-9][a-z0-9-]{0,49}$/.test(query.trim().toLowerCase()) && ( -
  • - -
  • - )} + {canCreate && ( +
  • selectOption(filtered.length)} + onMouseEnter={() => setActiveIndex(filtered.length)} + className={cn(optionClass(filtered.length), 'text-primary')} + > + Create new tag “{trimmedQuery}” +
  • + )} )}
    diff --git a/apps/web/tests/TagPicker.test.tsx b/apps/web/tests/TagPicker.test.tsx new file mode 100644 index 0000000..9a80d15 --- /dev/null +++ b/apps/web/tests/TagPicker.test.tsx @@ -0,0 +1,169 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { renderScreen, mockPaginated } from './test-utils.js'; +import { TagPicker } from '../src/components/TagPicker.js'; + +const TAGS = [ + { + id: 't1', + handle: 'topic.civic-tech', + namespace: 'topic', + slug: 'civic-tech', + title: 'Civic Tech', + projectCount: 3, + personCount: 2, + helpWantedCount: 0, + }, + { + id: 't2', + handle: 'topic.housing', + namespace: 'topic', + slug: 'housing', + title: 'Housing', + projectCount: 1, + personCount: 0, + helpWantedCount: 0, + }, +]; + +/** Drives TagPicker as a real consumer would — controlled `value`. */ +function Harness({ allowCreate = false }: { allowCreate?: boolean }) { + const [value, setValue] = useState([]); + return ( + + ); +} + +async function findCombobox() { + return waitFor(() => screen.getByRole('combobox', { name: 'Topics' })); +} + +describe('TagPicker', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/tags')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated(TAGS)), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('associates its label with the combobox input', async () => { + renderScreen(); + + // getByLabelText only resolves if