Browse and view members. The current-user mutations live on /api/auth/me (in api/auth.md) and the profile-update endpoints below.
See data-model.md.
| Method | Path | Auth | Summary |
|---|---|---|---|
GET |
/api/people |
public | Browse members. |
GET |
/api/people/:slug |
public | Fetch a single person's profile. |
PATCH |
/api/people/:slug |
self | staff | Update profile. |
POST |
/api/people/:slug/avatar |
self | staff | Upload an avatar image (multipart). |
PATCH |
/api/people/:slug/newsletter |
self | staff | Update newsletter opt-in state (private-store mutation; no public commit). |
POST |
/api/people/:slug/deactivate |
self | staff | Soft-deactivate (sets deletedAt). |
POST |
/api/people/:slug/reactivate |
self | staff | Reactivate (clears deletedAt). |
POST |
/api/people/:slug/purge |
administrator | Cascading hard-delete of person + their content. |
POST |
/api/people/:slug/account-level |
administrator | Change accountLevel (audit-logged). |
| Param | Type | Notes |
|---|---|---|
q |
string | Full-text on fullName, bio. |
tag |
string | Repeatable. Tag handle (e.g., tech.python). AND across repeats. |
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. |
sort |
sort | Default -createdAt. Allowed: createdAt, fullName. |
page, perPage |
int | Default perPage = 30. |
{
"success": true,
"data": [PersonListItem, ...],
"metadata": {
"page": 1, "perPage": 30, "totalItems": 1240,
"facets": {
"byTopic": [{ "tag": "topic.transit", "count": 41 }, ...],
"byTech": [...]
}
}
}{
"slug": "chris",
"fullName": "Chris Alfano",
"avatarUrl": "https://...",
"bioExcerpt": "First ~200 chars of bio, markdown stripped.",
"memberOfCount": 7, // active project memberships
"tags": [{ "namespace": "tech", "slug": "typescript", "title": "TypeScript" }, ...],
"createdAt": "..."
}{ "success": true, "data": Person }{
"id": "<uuid>",
"slug": "chris",
"fullName": "Chris Alfano",
"firstName": "Chris",
"lastName": "Alfano",
"avatarUrl": "https://...",
"bio": "Markdown source...",
"bioHtml": "<p>...</p>",
"slackHandle": "janedoe", // null if unset
"accountLevel": "staff", // visible to self and staff only; "user" otherwise
"deletedAt": null, // ISO timestamp if deactivated; null otherwise. Visible to self + staff only (always null to others)
"tags": { "topic": [Tag, ...], "tech": [Tag, ...] },
"memberships": [
{
"project": { "slug": "squadquest", "title": "SquadQuest", "stage": "testing" },
"role": "Founder",
"isMaintainer": true,
"joinedAt": "..."
}, ...
],
"recentUpdates": [ProjectUpdateSummary, ...], // last 5 updates this person authored
"permissions": { "canEdit": true, "canChangeAccountLevel": false },
"createdAt": "...",
"updatedAt": "..."
}Fields visible only to self or staff:
email(not in the shape above; added only when authorized)firstName,lastName(visible to all but editable only by self/staff)accountLevelvalue beyond a generic "user" — public callers always see"user"regardless of true leveldeletedAt— the real timestamp is shown to self + staff; everyone else always seesnull
404 not_found— slug doesn't exist, or person is soft-deleted and caller is not staff
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 endpoint — accountLevel passed in a generic PATCH body is rejected by the schema, not silently applied).
{
"fullName": "...",
"firstName": "...",
"lastName": "...",
"bio": "Markdown source",
"slug": "newslug",
"email": "new@example.com",
"slackHandle": "janedoe",
"tags": {
"topic": ["transit"],
"tech": ["typescript", "fastify"]
}
}Editable by self:
fullName,firstName,lastName,bio,tags,email,slug,slackHandle
Editable by staff additionally:
- (none — staff-only fields like
accountLevelget their own endpoint)
{ "success": true, "data": Person }409 conflict— slug or email taken422 validation_failed
Changing email does not log out other sessions in v1. The sign-out-everywhere mechanism (revoke all of a person's JWTs by jti) is documented in behaviors/authorization.md but not auto-triggered on email change.
Multipart upload, single file field image.
| Constraint | Value |
|---|---|
| Max size | 5 MB |
| Allowed types | image/png, image/jpeg, image/webp |
Server crops to a square and stores the original plus the 128x128 thumbnail as gitsheets attachments alongside the person's record (people/<slug>/avatar.jpg, people/<slug>/avatar-128.jpg). avatarKey is set to the relative path. Served via GET /api/attachments/<key>. See behaviors/storage.md.
{ "success": true, "data": { "avatarUrl": "https://..." } }Self or staff. Sets deletedAt = now(). Profile becomes 404 to non-staff. References to this person in other records render a placeholder. The person can still sign in and reactivate.
{ "success": true, "data": Person }403 forbidden— caller is not the person themselves, staff, or admin404 not_found— slug doesn't exist
Self or staff. Clears deletedAt. Person becomes visible again.
{ "success": true, "data": Person }403 forbidden— caller is not the person themselves, staff, or admin404 not_found— slug doesn't exist (even for non-staff, to allow self-reactivation)
Administrator-only. Atomically hard-deletes the person record and cascades: project-memberships, help-wanted-interest, person tag-assignments, project-updates (authored), project-buzz (posted), and blog-posts (authored). All in one gitsheets commit. Git-revertable.
Unlike the offline spam-prune (which nulls authorId on updates), purge DELETES the authored content — it is the on-demand garbage-collection path for spam accounts.
403 forbidden— caller is not an administrator404 not_found— slug doesn't exist
When a deactivated person is referenced in a serialized response (e.g. project member, update author, blog author, help-wanted postedBy), the reference must be substituted with a placeholder rather than omitted, so counts and history stay coherent:
{ "slug": null, "fullName": "Deactivated user", "avatarUrl": null, "deactivated": true }This placeholder shape applies to the PersonAvatar reference type used in: project memberships, project-update author, project-buzz postedBy, help-wanted postedBy/filledBy, and blog-post author.
Administrator-only. Changes a person's accountLevel. This is the only way to change accountLevel — it is deliberately a dedicated endpoint, not a field on the generic PATCH /api/people/:slug, so the privilege change is explicit and audit-logged.
{ "level": "staff" }level is one of user | staff | administrator. Setting the person's current level is an idempotent no-op (still 200).
Returns the updated person (same shape as GET /api/people/:slug), so the caller sees the new accountLevel.
The gitsheets commit carries Action: account-level.change plus Previous-Account-Level and New-Account-Level trailers (in addition to the standard actor/subject trailers), so privilege changes are traceable in the data-repo history.
Demoting the last administrator (the only person with accountLevel: administrator) is rejected with 422 — otherwise the change would lock everyone out of admin operations. This covers an admin demoting themselves when they are the sole administrator.
403 forbidden— caller is not an administrator404 not_found— slug doesn't exist422 validation_failed—levelmissing / not one of the three enum values (schema validation), or the change would demote the last administrator
POST /api/people/:slug/impersonate— admin-only. Starts a temporary impersonation session. Not in v1; flagged here so admin tooling has a place to grow into.