Skip to content

Latest commit

 

History

History
224 lines (174 loc) · 14.9 KB

File metadata and controls

224 lines (174 loc) · 14.9 KB
status done
depends
auth-jwt-substrate
read-api
specs
specs/api/projects.md
specs/api/projects-members.md
specs/api/projects-updates.md
specs/api/projects-buzz.md
specs/api/projects-help-wanted.md
specs/api/people.md
specs/api/tags.md
specs/behaviors/project-stages.md
specs/behaviors/tags.md
specs/behaviors/help-wanted-roles.md
specs/behaviors/slug-handles.md
specs/behaviors/authorization.md
issues
pr 29

Plan: Write API

Scope

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).

Implements

Approach

Service write methods

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' }.

Authorization

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.

Slug renames

When a PATCH /api/projects/:slug includes a new slug:

  1. Validate format + uniqueness
  2. Inside the same transaction: write the project at the new path, delete the old, write a SlugHistory record at slug-history/project/<oldSlug>.toml
  3. The web layer's redirect handler reads slug-history to serve 301s for 90 days

Same logic for person slug changes (rare; staff-only).

Cascade delete

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.

Help-wanted side effects

POST /api/projects/:slug/help-wanted/:roleId/fill with filledBySlug:

  1. Mutate the role record (status:'filled', filledAt, filledById)
  2. If filledBy isn't already a member: create a ProjectMembership with role: 'Help-wanted: <title>'
  3. Email the role poster via Resend (the apps/api/src/notify/ module shaped for email + slack-DM fan-out, with slack-DM stubbed)

POST /api/projects/:slug/help-wanted/:roleId/express-interest:

  1. Rate-cap: check the in-memory (roleId, personId) → lastInterestAt map (rebuilt at boot from the help-wanted-interest sheet)
  2. Upsert the HelpWantedInterestExpression record
  3. Notify the role poster (email; Slack DM later)

Newsletter PATCH (touches private store)

PATCH /api/people/:slug/newsletter accepts { optedIn: boolean }:

  1. store.transact with tx.private available
  2. Read current PrivateProfile, update newsletter.optedIn + optedInAt/optedOutAt + generate unsubscribeToken on first opt-in
  3. PUT the profiles.jsonl file
  4. No public-side write

This is a private-only mutation — no public commit produced. Documented in behaviors/private-storage.md.

Approach (absorbed deferrals)

Secondary in-memory indices via Sheet.defineIndex

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.

Private-store reconciliation script

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).

In-memory state invalidation hooks

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).

Authenticated permissions integration check

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.

Validation

  • Sheet.defineIndex calls are wired for all secondary indices in data-model.md; lookups verified in tests
  • apps/api/scripts/reconcile-private-store.ts exists and correctly flags/fixes orphan private records vs public Person list
  • POST /api/projects with valid body creates the project, founder membership, and tags in one commit; commit message + trailers match the documented shape
  • POST /api/projects from anonymous → 401
  • PATCH /api/projects/:slug enforces maintainer-or-staff
  • PATCH /api/projects/:slug with a new slug writes the new record, deletes the old, and adds a SlugHistory entry — all in one commit
  • DELETE /api/projects/:slug soft-deletes (deletedAt populated); subsequent GET returns 404 for non-staff
  • POST /api/projects/:slug/members (maintainer) adds; duplicate add returns 409 already_member
  • POST /api/projects/:slug/help-wanted then .../fill sets status, creates membership for filledBy, sends notification (verified via Resend mock)
  • POST .../express-interest enforces the 30-day rate cap per (personId, roleId)
  • PATCH /api/people/:slug/newsletter writes 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/:slug permissions block flips correctly across anonymous / member / maintainer / staff callers (verified with the auth-jwt-substrate session decorator populated)

Risks / unknowns

  • 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.

Notes

  • Schemas with denormalized path-template fields needed .passthrough(). gitsheets' path renderer reads projectSlug/personSlug/etc. off the validated record, but Zod 4 strips unknown keys by default. Switched ProjectMembershipSchema, ProjectUpdateSchema, ProjectBuzzSchema, HelpWantedRoleSchema, and HelpWantedInterestExpressionSchema to passthrough() so write services can attach those fields without a separate codepath. JSON Schema exports regenerated (additionalProperties: {}).
  • StateApply deferred-apply pattern. Write services build a StateApply inside the store.transact handler 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 do result.value.stateApply.apply(fastify.inMemoryState, fastify.fts) after each transact.
  • requireAuth(marker, ctx?) is the marker-vocabulary helper. The simpler request-level requireAuth(request, [markers]) from auth/guards.ts remained in place for the auth-routes that don't need entity context. The new one at auth/require.ts accepts marker expressions like 'maintainer | staff', 'self | staff', 'poster | maintainer | staff', with optional project/memberships/selfId/ownerId context. Services call it again at the service boundary for defense-in-depth.
  • Notifier is currently a logging stub. apps/api/src/notify/index.ts exposes 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 | staff marker 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 the ownerId alongside 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, gitsheets setAttachment). Tracked in Follow-ups.

Follow-ups

  • Deferred to github-oauth — replace the LoggingNotifier stub with a real Resend transport once OAuth + email-on-file flow lands.
  • Issue #32 — Implement POST /api/people/:slug/avatar multipart attachment route + image-resizing pipeline + storage at people/<slug>/avatar.jpg per 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-drift after this and public-screens land to catch any new gaps in coverage.