Skip to content

Commit ca3bcb3

Browse files
Merge pull request #148 from CodeForPhilly/chore/spec-drift-fixes
fix: resolve spec-drift audit findings (code + specs)
2 parents 23f3522 + 5889586 commit ca3bcb3

8 files changed

Lines changed: 118 additions & 57 deletions

File tree

‎apps/api/src/routes/people.ts‎

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,31 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
122122
tags: ['people'],
123123
summary: 'Update profile',
124124
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
125-
body: { type: 'object' },
125+
// Enumerate the editable fields and reject anything else, so privileged
126+
// fields (e.g. accountLevel — see POST /api/people/:slug/account-level)
127+
// can't be smuggled through the generic profile PATCH. Per
128+
// specs/api/people.md → "PATCH /api/people/:slug".
129+
body: {
130+
type: 'object',
131+
additionalProperties: false,
132+
properties: {
133+
fullName: { type: 'string' },
134+
firstName: { type: ['string', 'null'] },
135+
lastName: { type: ['string', 'null'] },
136+
bio: { type: ['string', 'null'] },
137+
slug: { type: 'string' },
138+
email: { type: 'string' },
139+
slackHandle: { type: ['string', 'null'] },
140+
tags: {
141+
type: 'object',
142+
additionalProperties: false,
143+
properties: {
144+
topic: { type: 'array', items: { type: 'string' } },
145+
tech: { type: 'array', items: { type: 'string' } },
146+
},
147+
},
148+
},
149+
},
126150
},
127151
}, async (request) => {
128152
const { slug } = request.params as { slug: string };
@@ -142,29 +166,6 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
142166
return ok(await fastify.services.people.get(result.value.person.slug, caller));
143167
});
144168

145-
// DELETE /api/people/:slug (admin-only soft-delete — legacy, kept for backward compat)
146-
fastify.delete('/api/people/:slug', {
147-
schema: {
148-
tags: ['people'],
149-
summary: 'Soft-delete a person (admin only, legacy)',
150-
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
151-
},
152-
}, async (request, reply) => {
153-
const { slug } = request.params as { slug: string };
154-
const result = await fastify.store.transact(
155-
buildTransactionOptions({
156-
request,
157-
action: 'person.soft-delete',
158-
subjectType: 'person',
159-
subjectSlug: slug,
160-
responseCode: 204,
161-
}),
162-
async (tx) => fastify.services.peopleWrite.softDelete(tx, slug, request.session),
163-
);
164-
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
165-
return reply.code(204).send();
166-
});
167-
168169
// POST /api/people/:slug/deactivate (self | staff)
169170
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
170171
fastify.post('/api/people/:slug/deactivate', {

‎apps/api/src/services/person.write.ts‎

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -166,31 +166,6 @@ export class PersonWriteService {
166166
return { person: updated, stateApply };
167167
}
168168

169-
async softDelete(
170-
tx: DualStoreTx,
171-
slug: string,
172-
session: SessionContext,
173-
): Promise<{ stateApply: StateApply }> {
174-
const existing = this.#personOrThrow(slug);
175-
requireAuth('administrator', { session });
176-
177-
if (existing.deletedAt) {
178-
return { stateApply: new StateApply() };
179-
}
180-
181-
const now = nowIso();
182-
const updated: Person = PersonSchema.parse({
183-
...existing,
184-
deletedAt: now,
185-
updatedAt: now,
186-
});
187-
188-
await tx.public.people.upsert(updated);
189-
190-
const stateApply = new StateApply().upsertPerson(updated);
191-
return { stateApply };
192-
}
193-
194169
/**
195170
* Deactivate — self-service or staff.
196171
* Sets `deletedAt = now()`. The person can still sign in and reactivate.

‎apps/api/src/store/commit-meta.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* The User-Ip / User-Agent / Authorization / Cookie headers are deliberately
99
* NOT included — the public commit log is forever-public.
1010
*/
11+
import { STATUS_CODES } from 'node:http';
1112
import type { Author, TransactionOptions } from 'gitsheets';
1213
import type { FastifyRequest } from 'fastify';
1314
import type { SessionContext } from '../auth/middleware.js';
@@ -61,6 +62,9 @@ export function buildTransactionOptions(ctx: CommitContext): TransactionOptions
6162
Host: request.hostname || 'unknown',
6263
'Content-Type': String(request.headers['content-type'] ?? 'unknown'),
6364
'Response-Code': String(responseCode),
65+
// HTTP reason phrase for the response code, per
66+
// specs/behaviors/storage.md#commit-message-shape.
67+
'Response-Message': STATUS_CODES[responseCode] ?? 'Unknown',
6468
};
6569

6670
if (subjectType) trailers['Subject-Type'] = subjectType;

‎plans/spec-drift-fixes.md‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
status: done
3+
depends: []
4+
specs:
5+
- specs/behaviors/storage.md
6+
- specs/api/people.md
7+
- specs/api/conventions.md
8+
- specs/behaviors/legacy-id-mapping.md
9+
issues: []
10+
pr: 148
11+
---
12+
13+
# Plan: spec-drift audit fixes
14+
15+
## Scope
16+
17+
A spec-drift audit (two scoped `spec-drift-auditor` runs) compared `specs/`
18+
against the implementation. This plan records the resolution of the real
19+
findings — and the false positives that were verified away rather than
20+
"fixed."
21+
22+
## Real findings fixed
23+
24+
**Code (apps/api):**
25+
26+
- **Removed the legacy `DELETE /api/people/:slug` route + `softDelete` service
27+
method.** It was an undocumented admin-only soft-delete running parallel to
28+
the canonical self|staff `POST /deactivate` — two soft-delete paths with
29+
diverging authz. Superseded by deactivate/reactivate/purge; deleted.
30+
- **`commit-meta.ts`: added the `Response-Message` trailer** (HTTP reason
31+
phrase via `node:http` `STATUS_CODES`) — `storage.md#commit-message-shape`
32+
required it but only `Response-Code` was emitted.
33+
- **`PATCH /api/people/:slug`: tightened the body schema** to enumerate the
34+
editable fields + `additionalProperties: false`, so privileged fields (esp.
35+
`accountLevel`) are rejected (422) rather than silently ignored — hardening
36+
the "account-level only via its dedicated endpoint" rule.
37+
38+
**Specs/docs:**
39+
40+
- **`storage.md` public/private contradiction** — ground-truthed the repo
41+
visibility (`codeforphilly-data` is **private** on GitHub today). Reconciled:
42+
it's a private repo holding **public-by-design** content; the dangerous table
43+
claim that it "contains emails, real names, IPs" (contradicting the whole
44+
redaction section) was corrected — PII lives in the private store.
45+
- `people.md` Person response shape: documented `slackHandle` and `deletedAt`
46+
(both returned by the serializer; `deletedAt` gated to self/staff).
47+
- `people.md`: clarified `?accountLevel=` returns an empty 200 to non-staff
48+
(not 403); corrected the PATCH note to point at the dedicated admin endpoint.
49+
- `legacy-id-mapping.md`: tightened the `byLegacyId` claim — runtime indices
50+
exist only for people/projects/blog-posts; tags/buzz/updates carry `legacyId`
51+
for import idempotence only.
52+
- `storage.md`: fixed the `scrub-data.ts` path; clarified that commit bodies
53+
carry only the caller `summary` (request-body PII redaction is moot).
54+
- `conventions.md`: marked Idempotency-Key as built-but-not-yet-wired (status
55+
note) — the actual per-route wiring is a follow-up.
56+
57+
## Verified false positives (NOT changed)
58+
59+
- **`reloadInMemoryState` vs `swapPublic`** — the hot-reload path *does* call
60+
`store.swapPublic` (`reload.ts:76`); the spec was correct. Auditor only read
61+
`internal.ts`.
62+
- **`perPage` out-of-range "returns 400"** — this app remaps Fastify validation
63+
errors to **422** (`errors.ts:179`). Spec is correct.
64+
- **account-level `Previous-Account-Level: unknown` "race"** — the 404 throws
65+
inside the transaction before any write, so no commit (or trailer) persists.
66+
67+
## Validation
68+
69+
- [x] `type-check` + `lint` clean.
70+
- [x] people-account-level 10/10, people-lifecycle 14/14, write-api 28/28
71+
(52 total across affected suites).
72+
73+
## Follow-ups
74+
75+
- Wire `Idempotency-Key` into at-risk mutating endpoints (starting with
76+
`POST /api/projects/:slug/updates`) + per-route tests.

‎specs/api/conventions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ Exceeded → `429 rate_limited`, `Retry-After` header in seconds.
170170

171171
Mutating endpoints accept an optional `Idempotency-Key` header (any client-generated string). The API caches the response in-memory keyed by `(personId, idempotencyKey)` for 24 hours; repeat requests with the same key return the same response. In-memory by design — single replica, restart-tolerant: a key that hasn't seen a duplicate within 24h won't see one after a restart either. This matters for cases like "post project update" where a double-tap shouldn't create two updates.
172172

173+
> **Status:** the idempotency plugin (`apps/api/src/plugins/idempotency.ts`) is implemented and registered, but not yet wired into any route handler — mutating endpoints don't honor the header *yet*. Wiring the at-risk endpoints (starting with `POST /api/projects/:slug/updates`) and the per-route tests are tracked as a follow-up; until then, treat this section as the target contract, not current behavior.
174+
173175
## Logging and trace IDs
174176

175177
Every request has a `traceId` (UUIDv7). It's included in logs and surfaced in error responses' `error.traceId`. If a user reports a problem, the traceId is the link to the server logs.

‎specs/api/people.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ See [data-model.md](../data-model.md#person).
2626
| ----- | ---- | ----- |
2727
| `q` | string | Full-text on `fullName`, `bio`. |
2828
| `tag` | string | Repeatable. Tag handle (e.g., `tech.python`). AND across repeats. |
29-
| `accountLevel` | enum | `user` \| `staff` \| `administrator`. Staff-only filter. |
29+
| `accountLevel` | enum | `user` \| `staff` \| `administrator`. Staff-only filter — for a non-staff caller it returns an empty list (a 200 with no items), not a `403`, so the filter's existence isn't a signal. |
3030
| `sort` | sort | Default `-createdAt`. Allowed: `createdAt`, `fullName`. |
3131
| `page`, `perPage` | int | Default `perPage = 30`. |
3232

@@ -80,7 +80,9 @@ See [data-model.md](../data-model.md#person).
8080
"avatarUrl": "https://...",
8181
"bio": "Markdown source...",
8282
"bioHtml": "<p>...</p>",
83+
"slackHandle": "janedoe", // null if unset
8384
"accountLevel": "staff", // visible to self and staff only; "user" otherwise
85+
"deletedAt": null, // ISO timestamp if deactivated; null otherwise. Visible to self + staff only (always null to others)
8486
"tags": { "topic": [Tag, ...], "tech": [Tag, ...] },
8587
"memberships": [
8688
{
@@ -102,14 +104,15 @@ Fields visible only to self or staff:
102104
- `email` (not in the shape above; added only when authorized)
103105
- `firstName`, `lastName` (visible to all but editable only by self/staff)
104106
- `accountLevel` value beyond a generic "user" — public callers always see `"user"` regardless of true level
107+
- `deletedAt` — the real timestamp is shown to self + staff; everyone else always sees `null`
105108

106109
### Errors
107110

108111
- `404 not_found` — slug doesn't exist, or person is soft-deleted and caller is not staff
109112

110113
## PATCH /api/people/:slug
111114

112-
Self or staff. Self cannot change their own `accountLevel`; only administrators can change account levels (and only via a staff-only sub-endpoint, not by passing `accountLevel` in a generic PATCH).
115+
Self or staff. Self cannot change their own `accountLevel`; only administrators can change account levels (and only via the dedicated [`POST /api/people/:slug/account-level`](#post-apipeopleslugaccount-level) endpoint — `accountLevel` passed in a generic PATCH body is rejected by the schema, not silently applied).
113116

114117
### Request
115118

‎specs/behaviors/legacy-id-mapping.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ If we ever need to re-import (e.g., catching up on changes made after cutover),
2525

2626
## Lookup
2727

28-
`legacyId` lookups go through the in-memory `byLegacyId.<entity>` indices documented in [data-model.md](../data-model.md). These indices are built at boot by iterating the sheet and skipping records where `legacyId` is absent.
28+
`legacyId` lookups that back a live redirect go through in-memory `byLegacyId` indices, built at boot by iterating the sheet and skipping records where `legacyId` is absent. These indices exist only for the entities whose laddr URLs actually referenced numeric IDs — **`people`, `projects`, and `blog-posts`**. The other migrated sheets (`tags`, `project-buzz`, `project-updates`) carry `legacyId` for *import idempotence* but have no runtime `byLegacyId` index, because no live URL resolves them by numeric ID (their legacy URLs were always slug-based). If a future redirect ever needs one of those by numeric ID, add the index then.
2929

3030
Uniqueness of `legacyId` per sheet is enforced by the API's write mutex: a record's `legacyId` is checked against the index before commit, just like `slug` and `email`.
3131

‎specs/behaviors/storage.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Rule
44

5-
**Public** persistent data lives in a **gitsheets-backed git repository** — TOML records in templated paths, committed atomically, pushed to a publicly cloneable GitHub remote. There is no relational database. At runtime the API loads all records into typed in-memory structures and serves reads from there; mutations write to the gitsheets repo and update the in-memory state synchronously.
5+
**Public-by-design** persistent data lives in a **gitsheets-backed git repository** — TOML records in templated paths, committed atomically, pushed to the `codeforphilly-data` GitHub remote. The repo is access-controlled (private) today, but its contents are curated to be publicly shareable (civic transparency), and the commit log is redacted of PII accordingly — treat everything in it as forever-public (see [PII-aware redaction](#pii-aware-redaction)). There is no relational database. At runtime the API loads all records into typed in-memory structures and serves reads from there; mutations write to the gitsheets repo and update the in-memory state synchronously.
66

77
**Private** data — emails, password hashes during migration, newsletter subscription state — lives in a separate S3-compatible bucket. See [behaviors/private-storage.md](private-storage.md). This spec covers only the public side.
88

@@ -54,7 +54,7 @@ There are two git repositories:
5454
| Repo | Purpose | Visibility |
5555
| ---- | ------- | ---------- |
5656
| `codeforphilly-rewrite` | The application code (this repo) | Public |
57-
| `codeforphilly-data` | The live gitsheets data | **Private** — contains emails, real names, IPs |
57+
| `codeforphilly-data` | The live gitsheets data | **Private repo today**, but **public-by-design** content — member names + projects are public-facing; the commit log is redacted (no emails, IPs, or password hashes — those live in the private store). Treat as forever-public. |
5858

5959
The code repo references the data repo by env (`CFP_DATA_REPO_PATH`). They are not git submodules — too much friction. Sibling clones, locally and in production.
6060

@@ -81,7 +81,7 @@ A scrubbed snapshot of the data repo is published as a public tag (e.g., `snapsh
8181
- `slackHandle` → null
8282
- `bio`, `overview`, `body` content → unchanged (assumed safe; staff may flag specific records for redaction)
8383

84-
A `scripts/scrub-data.ts` in the code repo produces the snapshot. The contributor bootstrap is:
84+
An `apps/api/scripts/scrub-data.ts` in the code repo produces the snapshot. The contributor bootstrap is:
8585

8686
```bash
8787
git clone https://github.com/CodeForPhilly/codeforphilly-rewrite.git
@@ -264,12 +264,12 @@ Rules:
264264

265265
### PII-aware redaction
266266

267-
The public data repo is, well, public — redaction is essential, not defense-in-depth:
267+
The data repo is public-by-design (and may be made public outright) — redaction is essential, not defense-in-depth:
268268

269269
- **No real emails as commit authors.** Author = `<slug>@users.noreply.codeforphilly.org`, never the user's actual email.
270270
- **No `User-Ip` or `User-Agent` trailers in public commits** (rule above).
271271
- `Authorization` and `Cookie` request headers are never embedded anywhere.
272-
- Any request body field matching `/email|password|token|secret/i` is replaced with `[REDACTED]` before embedding in the commit message body.
272+
- **Commit message bodies carry only the caller-provided `summary` string — never the raw request body** — so request PII can't reach the commit message in the first place. If a `summary` is ever derived from request input, fields matching `/email|password|token|secret/i` must be `[REDACTED]` before embedding.
273273
- `Set-Cookie` response headers and JWT bodies are not embedded.
274274
- Trailers for actions on the private store (`PrivateProfile`, `LegacyPasswordCredential`) appear in the public log as semantic action only — `Action: private-profile.update`, with no field-level diff. The actual change details live in the private bucket's object-version history (see [private-storage.md](private-storage.md)).
275275

0 commit comments

Comments
 (0)