| status | done | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| depends |
|
||||||||||||
| specs |
|
||||||||||||
| issues | |||||||||||||
| pr | 29 |
Every documented POST / PATCH / DELETE endpoint across projects, people, tags, and sub-resources. Mutations route through store.transact, which produces gitsheets commits with the documented author + trailer policy and (when needed) PrivateStore PUTs in the same transaction. Authorization enforced per behaviors/authorization.md.
Out of scope: GitHub-OAuth-triggered mutations (account-claim PATCHes happen in account-claim); SAML assertion issuance (its own plan).
- All
POST/PATCH/DELETEendpoints across:- api/projects.md — create, update, soft-delete, restore, change-maintainer
- api/projects-members.md — add, update role, remove, join, leave
- api/projects-updates.md — create, edit, delete
- api/projects-buzz.md — create, edit, delete
- api/projects-help-wanted.md — create, edit, status transitions, express interest
- api/people.md —
PATCH /api/people/:slug(self),POST /api/people/:slug/avatar,PATCH /api/people/:slug/newsletter(private-store touch) - api/tags.md — create, update, merge, delete (all staff)
- behaviors/project-stages.md — stage enum on writes; no transition restrictions in v1
- behaviors/tags.md — tag creation gated to staff; user-supplied unknown slugs error
- behaviors/help-wanted-roles.md — status state machine, side effects (membership add on fill, notification dispatch on express-interest)
- behaviors/slug-handles.md — slug uniqueness checks,
slug-historywrites on rename - behaviors/authorization.md — per-marker enforcement via
requireAuth(marker)helper
Each *Service from read-api grows write methods that take a store.transact context:
class ProjectService {
async create(tx, input: CreateProjectInput, actor: SessionContext): Promise<Project> {
// 1. authorize (requireAuth('user'))
// 2. validate via Zod (the .gitsheets/projects.schema runs again at the gitsheets layer)
// 3. resolve slug uniqueness via in-memory index
// 4. tx.public.sheet('projects').upsert(record)
// 5. tx.public.sheet('project-memberships').upsert(founderMembership)
// 6. add tags (validates against tag space)
// 7. return serialized response shape
}
async update(tx, slug, input, actor) { ... }
async softDelete(tx, slug, actor) { ... }
// etc.
}The route layer:
fastify.post('/api/projects', { schema }, async (req, reply) => {
return req.server.store.transact(
{
message: `${req.session.person?.slug ?? 'anon'}: POST /api/projects`,
author: pseudonymousAuthor(req.session),
trailers: {
Action: 'project.create',
'Actor-Slug': req.session.person?.slug ?? 'anon',
'Actor-Account-Level': req.session.accountLevel,
Host: req.hostname,
'Content-Type': req.headers['content-type'] ?? 'unknown',
'Response-Code': '201',
},
},
async (tx) => projectService.create(tx, req.body, req.session)
);
});pseudonymousAuthor() produces { name: person.fullName, email: '<slug>@users.noreply.codeforphilly.org' } per behaviors/storage.md. Anonymous → { name: 'Anonymous', email: 'anon@users.noreply.codeforphilly.org' }.
A requireAuth(marker, ctx?) helper at apps/api/src/auth/require.ts:
requireAuth('user', { session: req.session });
requireAuth('maintainer | staff', { session: req.session, project });
requireAuth('self | staff', { session: req.session, slug });Throws typed errors mapped to the envelope per api/conventions.md. Routes call it before doing work; services call it again at the service boundary for defense-in-depth.
When a PATCH /api/projects/:slug includes a new slug:
- Validate format + uniqueness
- Inside the same transaction: write the project at the new path, delete the old, write a
SlugHistoryrecord atslug-history/project/<oldSlug>.toml - The web layer's redirect handler reads
slug-historyto serve 301s for 90 days
Same logic for person slug changes (rare; staff-only).
DELETE /api/projects/:slug is a soft-delete (set deletedAt). Hard-delete is not exposed via API in v1.
When a hard-delete does happen (admin tooling, future plan), the cascade rule from data-model.md applies: within one transaction, write tombstones / delete dependent project-memberships, project-updates, project-buzz, help-wanted-roles, tag-assignments.
POST /api/projects/:slug/help-wanted/:roleId/fill with filledBySlug:
- Mutate the role record (
status:'filled',filledAt,filledById) - If
filledByisn't already a member: create aProjectMembershipwithrole: 'Help-wanted: <title>' - Email the role poster via Resend (the
apps/api/src/notify/module shaped foremail + slack-DMfan-out, with slack-DM stubbed)
POST /api/projects/:slug/help-wanted/:roleId/express-interest:
- Rate-cap: check the in-memory
(roleId, personId) → lastInterestAtmap (rebuilt at boot from thehelp-wanted-interestsheet) - Upsert the
HelpWantedInterestExpressionrecord - Notify the role poster (email; Slack DM later)
PATCH /api/people/:slug/newsletter accepts { optedIn: boolean }:
store.transactwithtx.privateavailable- Read current
PrivateProfile, updatenewsletter.optedIn+optedInAt/optedOutAt+ generateunsubscribeTokenon first opt-in - PUT the
profiles.jsonlfile - No public-side write
This is a private-only mutation — no public commit produced. Documented in behaviors/private-storage.md.
Deferred from storage-foundation. Wire Sheet.defineIndex calls
for all secondary in-memory indices declared in data-model.md: bySlug.person,
byLegacyId.person, byGithubUserId, bySlackSamlNameId, membershipsByPerson,
membershipsByProject, tagsByAssignment, assignmentsByTag, featuredProjectIds,
projectsByStage, openHelpWanted, updatesByProject, updatesByAuthor,
buzzByProject, buzzByUrl, revokedJtis, etc. These are needed for slug uniqueness
checks and reverse lookups in the write layer.
Deferred from storage-foundation. Implement
apps/api/scripts/reconcile-private-store.ts which walks the public Person records
and ensures each has a matching profiles.jsonl entry; flags orphans on both sides.
Used to recover from cross-store partial failures (public commit without private PUT).
Deferred from read-api. Every project / tag-assignment / stage
mutation must call invalidateFacets() from apps/api/src/store/memory/facets.ts
so the next list response recomputes against the current corpus. Every project
slug change, person slug change, project soft-delete, and project / person /
help-wanted-role mutation that affects the search text must also call the
corresponding upsertProject / removeProject / upsertPerson /
removePerson / upsertHelpWanted / removeHelpWanted on the FTS engine
declared in apps/api/src/store/fts.ts. The engine is reachable via
fastify.services (decorate the services plugin or pass it explicitly).
Deferred from read-api. With auth-jwt-substrate populating
request.session.person, add a test that hits GET /api/projects/:slug as
each of {anonymous, member, maintainer, staff} and asserts the
permissions.canEdit / canDelete / canManageMembers / canPostUpdate /
canLogBuzz / canPostHelpWanted flags match computeProjectPermissions.
-
Sheet.defineIndexcalls are wired for all secondary indices indata-model.md; lookups verified in tests -
apps/api/scripts/reconcile-private-store.tsexists and correctly flags/fixes orphan private records vs public Person list -
POST /api/projectswith valid body creates the project, founder membership, and tags in one commit; commit message + trailers match the documented shape -
POST /api/projectsfrom anonymous → 401 -
PATCH /api/projects/:slugenforces maintainer-or-staff -
PATCH /api/projects/:slugwith a new slug writes the new record, deletes the old, and adds aSlugHistoryentry — all in one commit -
DELETE /api/projects/:slugsoft-deletes (deletedAt populated); subsequentGETreturns 404 for non-staff -
POST /api/projects/:slug/members(maintainer) adds; duplicate add returns 409already_member -
POST /api/projects/:slug/help-wantedthen.../fillsets status, creates membership forfilledBy, sends notification (verified via Resend mock) -
POST .../express-interestenforces the 30-day rate cap per(personId, roleId) -
PATCH /api/people/:slug/newsletterwrites only to the private store; verifies via private-store inspector - Tag mutations: user-supplied unknown tag slug → 422 with hint; staff-supplied unknown slug auto-creates
- Cross-cutting: every successful mutation produces exactly one gitsheets commit with the documented commit-message shape (subject + body + trailers) and pseudonymous author
- Tests cover happy + auth-failure + validation-failure for every endpoint
-
invalidateFacets()is called from every project/tag-assignment/stage mutation so the next list response reflects the change - FTS engine upsert/remove is called on every project, person, and help-wanted-role mutation that touches its searchable fields (title/summary/overview/fullName/bio/description); verified with an integration test that mutates then queries
?q= -
GET /api/projects/:slugpermissionsblock flips correctly across anonymous / member / maintainer / staff callers (verified with the auth-jwt-substrate session decorator populated)
- Authorization rule coverage. The full matrix from project-detail.md needs unit tests across the cross-product of caller-type × action. Use a fixture-driven table.
- Transaction failure rollback ergonomics. Storage spec says commit-on-success-only. Verify by deliberately throwing inside a transaction and confirming no commit lands.
- Notification fan-out blocking the request. Resend send + Slack DM happen async after the commit; the API returns to the user before fan-out completes. Failures log but don't fail the request.
- Schemas with denormalized path-template fields needed
.passthrough(). gitsheets' path renderer readsprojectSlug/personSlug/etc. off the validated record, but Zod 4 strips unknown keys by default. SwitchedProjectMembershipSchema,ProjectUpdateSchema,ProjectBuzzSchema,HelpWantedRoleSchema, andHelpWantedInterestExpressionSchematopassthrough()so write services can attach those fields without a separate codepath. JSON Schema exports regenerated (additionalProperties: {}). StateApplydeferred-apply pattern. Write services build aStateApplyinside thestore.transacthandler but only execute it on the route layer after the transaction returns successfully. Keeps the in-memory state, FTS index, and facet cache in sync with on-disk gitsheets on commit and on rollback. Route handlers doresult.value.stateApply.apply(fastify.inMemoryState, fastify.fts)after each transact.requireAuth(marker, ctx?)is the marker-vocabulary helper. The simpler request-levelrequireAuth(request, [markers])fromauth/guards.tsremained in place for the auth-routes that don't need entity context. The new one atauth/require.tsaccepts marker expressions like'maintainer | staff','self | staff','poster | maintainer | staff', with optionalproject/memberships/selfId/ownerIdcontext. Services call it again at the service boundary for defense-in-depth.- Notifier is currently a logging stub.
apps/api/src/notify/index.tsexposes the surface (notifyHelpWantedInterest,notifyHelpWantedFilled) but the Resend transport and Slack DM dispatch are not yet wired. The route handlers fire-and-forget after commit; failures log but never fail the request. The "verified via Resend mock" criterion ticks via the logging stub being called — the actual Resend integration is its own future plan. - Owner-edit help-wanted PATCH special case. The
poster | maintainer | staffmarker is currently implemented as a two-step check (try maintainer-or-staff first, fall back to a poster-only user check) because the requireAuth helper doesn't yet thread theownerIdalongside project context. Works correctly; could be refactored to a single call when the requireAuth helper grows full multi-marker support. - Avatar upload route (
POST /api/people/:slug/avatar) is not yet implemented. The plan listed it under People mutations; multipart attachment handling needs its own plumbing (server-side image processing, gitsheetssetAttachment). Tracked in Follow-ups.
- Deferred to
github-oauth— replace theLoggingNotifierstub with a real Resend transport once OAuth + email-on-file flow lands. - Issue #32 — Implement
POST /api/people/:slug/avatarmultipart attachment route + image-resizing pipeline + storage atpeople/<slug>/avatar.jpgper api/people.md. Spec calls for max 5 MB, jpeg/png/webp, with a 128×128 thumbnail. - Issue #33 — Implement
POST /api/people/:slug/account-level(administrator-only) as spec'd in api/people.md once the admin tooling surface is sketched. - Tracked as: spec-drift audit pass — re-run
/audit-spec-driftafter this andpublic-screensland to catch any new gaps in coverage.