diff --git a/apps/api/src/routes/people.ts b/apps/api/src/routes/people.ts index a107c71..f8f2378 100644 --- a/apps/api/src/routes/people.ts +++ b/apps/api/src/routes/people.ts @@ -6,6 +6,7 @@ * POST /api/people/:slug/deactivate * POST /api/people/:slug/reactivate * POST /api/people/:slug/purge + * POST /api/people/:slug/account-level (administrator) * PATCH /api/people/:slug/newsletter (private-only mutation) */ import type { FastifyInstance } from 'fastify'; @@ -238,6 +239,51 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { return reply.code(204).send(); }); + // POST /api/people/:slug/account-level (administrator only) + // Spec: specs/api/people.md → POST /api/people/:slug/account-level + fastify.post('/api/people/:slug/account-level', { + schema: { + tags: ['people'], + summary: "Change a person's account level (admin only)", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: 'object', + properties: { level: { type: 'string', enum: ['user', 'staff', 'administrator'] } }, + required: ['level'], + additionalProperties: false, + }, + }, + }, async (request) => { + const { slug } = request.params as { slug: string }; + const { level } = request.body as { level: Person['accountLevel'] }; + + // Resolve the current level up-front so the audit trailers capture the + // before/after. Reads in-memory state, which is current under the write + // mutex; if the slug is unknown, setAccountLevel below 404s. + const existingId = fastify.inMemoryState.personIdBySlug.get(slug); + const previousLevel = existingId + ? fastify.inMemoryState.people.get(existingId)?.accountLevel + : undefined; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-level.change', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + extraTrailers: { + 'Previous-Account-Level': previousLevel ?? 'unknown', + 'New-Account-Level': level, + }, + }), + async (tx) => fastify.services.peopleWrite.setAccountLevel(tx, slug, level, request.session), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + const caller = getCallerSession(request); + return ok(await fastify.services.people.get(result.value.person.slug, caller)); + }); + // PATCH /api/people/:slug/newsletter (private-store only — no public commit) fastify.patch('/api/people/:slug/newsletter', { schema: { diff --git a/apps/api/src/services/person.write.ts b/apps/api/src/services/person.write.ts index 739ad23..41e78ec 100644 --- a/apps/api/src/services/person.write.ts +++ b/apps/api/src/services/person.write.ts @@ -354,6 +354,60 @@ export class PersonWriteService { return { stateApply }; } + /** + * Set a person's accountLevel — administrator only. This is the sole path + * for changing accountLevel (never via the generic PATCH), so the privilege + * change is explicit and audit-logged. Rejects demoting the last + * administrator with a 422 to avoid locking everyone out. + * Spec: specs/api/people.md → POST /api/people/:slug/account-level + */ + async setAccountLevel( + tx: DualStoreTx, + slug: string, + level: Person['accountLevel'], + session: SessionContext, + ): Promise<{ person: Person; previousLevel: Person['accountLevel']; stateApply: StateApply }> { + requireAuth('administrator', { session }); + + const id = this.#state.personIdBySlug.get(slug); + if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`); + const existing = this.#state.people.get(id); + if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + + const previousLevel = existing.accountLevel; + + // Idempotent: setting the current level is a no-op (no commit). + if (previousLevel === level) { + return { person: existing, previousLevel, stateApply: new StateApply() }; + } + + // Last-administrator guard: never let the count of administrators reach + // zero, or admin operations become unreachable for everyone (this covers + // an admin demoting themselves while sole administrator). + if (previousLevel === 'administrator' && level !== 'administrator') { + let adminCount = 0; + for (const p of this.#state.people.values()) { + if (p.accountLevel === 'administrator') adminCount += 1; + } + if (adminCount <= 1) { + throw new ApiValidationError( + 'Cannot demote the last administrator — at least one administrator must remain.', + { level: 'last_administrator' }, + ); + } + } + + const updated: Person = PersonSchema.parse({ + ...existing, + accountLevel: level, + updatedAt: nowIso(), + }); + + await tx.public.people.upsert(updated); + const stateApply = new StateApply().upsertPerson(updated); + return { person: updated, previousLevel, stateApply }; + } + async updateNewsletter( slug: string, optedIn: boolean, diff --git a/apps/api/tests/people-account-level.test.ts b/apps/api/tests/people-account-level.test.ts new file mode 100644 index 0000000..364aa50 --- /dev/null +++ b/apps/api/tests/people-account-level.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for POST /api/people/:slug/account-level (administrator-only). + * + * Spec: specs/api/people.md → POST /api/people/:slug/account-level + * + * Covers: + * - anonymous → 401 + * - regular user caller → 403 + * - staff (non-admin) caller → 403 + * - invalid level → 422 (schema validation) + * - admin promotes user → 200, level reflected + * - admin demotes staff → 200 + * - idempotent no-op (same level) → 200 + * - last-administrator self-demotion → 422 + * - demoting an admin while another admin exists → 200 + * - audit trail: commit carries Action + Previous/New-Account-Level trailers + */ +import { execFileSync } from 'node:child_process'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { buildApp } from '../src/app.js'; +import { mintSessionFor } from '../src/auth/issue.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +const IDS = { + alice: '01951a3d-0000-7000-8000-a0000000cafe', + bob: '01951a3d-0000-7000-8000-b0000000cafe', + staff: '01951a3d-0000-7000-8000-c0000000cafe', + admin: '01951a3d-0000-7000-8000-d0000000cafe', +}; + +async function mintCookies( + personId: string, + level: 'user' | 'staff' | 'administrator' = 'user', +): Promise { + const { accessToken } = await mintSessionFor(personId, level, JWT_KEY); + return `cfp_session=${accessToken}`; +} + +interface PersonDetailResponse { + success: boolean; + data: { slug: string; accountLevel?: string }; +} + +describe('POST /api/people/:slug/account-level', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + + for (const [slug, id, level] of [ + ['alice', IDS.alice, 'user'], + ['bob', IDS.bob, 'user'], + ['staff-user', IDS.staff, 'staff'], + ['admin-user', IDS.admin, 'administrator'], + ] as const) { + const toml = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${level}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ].join('\n'); + await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`); + } + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + const post = (slug: string, body: unknown, cookie?: string) => + app.inject({ + method: 'POST', + url: `/api/people/${slug}/account-level`, + headers: cookie ? { cookie } : {}, + payload: body as object, + }); + + it('anonymous → 401', async () => { + const res = await post('alice', { level: 'staff' }); + expect(res.statusCode).toBe(401); + }); + + it('regular user caller → 403', async () => { + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.bob)); + expect(res.statusCode).toBe(403); + }); + + it('staff (non-admin) caller → 403', async () => { + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.staff, 'staff')); + expect(res.statusCode).toBe(403); + }); + + it('invalid level → 422 (schema validation)', async () => { + const res = await post('alice', { level: 'superuser' }, await mintCookies(IDS.admin, 'administrator')); + expect(res.statusCode).toBe(422); + }); + + it('admin promotes a user to staff → 200, level reflected', async () => { + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator')); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + // accountLevel is visible to the admin (staff-level) caller. + expect(body.data.accountLevel).toBe('staff'); + }); + + it('admin demotes staff to user → 200', async () => { + const res = await post('staff-user', { level: 'user' }, await mintCookies(IDS.admin, 'administrator')); + expect(res.statusCode).toBe(200); + expect(res.json().data.accountLevel).toBe('user'); + }); + + it('idempotent: setting the same level → 200 no-op', async () => { + // alice is now 'staff' from the promote test. + const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator')); + expect(res.statusCode).toBe(200); + expect(res.json().data.accountLevel).toBe('staff'); + }); + + it('last administrator self-demotion → 422', async () => { + // admin-user is the sole administrator. + const res = await post('admin-user', { level: 'user' }, await mintCookies(IDS.admin, 'administrator')); + expect(res.statusCode).toBe(422); + }); + + it('can demote an administrator when another administrator exists → 200', async () => { + const adminCookie = await mintCookies(IDS.admin, 'administrator'); + // Promote bob to administrator first (now two admins). + const promote = await post('bob', { level: 'administrator' }, adminCookie); + expect(promote.statusCode).toBe(200); + // Now admin-user can be demoted — bob remains. + const demote = await post('admin-user', { level: 'staff' }, adminCookie); + expect(demote.statusCode).toBe(200); + expect(demote.json().data.accountLevel).toBe('staff'); + }); + + it('audit trail: commit carries Action + Previous/New-Account-Level trailers', async () => { + // Make a fresh change and inspect the resulting commit on the bare repo. + await post('staff-user', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator')); + const msg = execFileSync('git', ['-C', dataRepo.path, 'log', '-1', '--format=%B', 'main'], { + encoding: 'utf8', + }); + expect(msg).toContain('Action: account-level.change'); + expect(msg).toContain('Previous-Account-Level: user'); + expect(msg).toContain('New-Account-Level: staff'); + }); +}); diff --git a/plans/person-account-level.md b/plans/person-account-level.md new file mode 100644 index 0000000..d9738fd --- /dev/null +++ b/plans/person-account-level.md @@ -0,0 +1,62 @@ +--- +status: done +depends: [] +specs: + - specs/api/people.md +issues: + - 33 +pr: 147 +--- + +# Plan: person account-level change endpoint + +## Scope + +Deferred from write-api (#29): `accountLevel` was listed under People mutations +but never shipped a dedicated endpoint. The spec called for it as the *only* +way to change `accountLevel` (never via the generic `PATCH`), so the privilege +change is explicit and audit-logged. Issue #33. + +## Implements + +- [api/people.md](../specs/api/people.md) — `POST /api/people/:slug/account-level` + (administrator-only): request body, 200 response, audit trailers, + last-administrator guard, error table. Promoted from a deferred stub to a + full section. + +## Approach + +- **Write service** `PersonWriteService.setAccountLevel(tx, slug, level, session)`: + `requireAuth('administrator')`; idempotent no-op when the level is unchanged; + **last-administrator guard** — refuse to demote the only `administrator` + (counts admins in state; `<= 1` → `ApiValidationError` → 422); returns the + updated person + `previousLevel`. +- **Route** `POST /api/people/:slug/account-level`: body schema validates the + `level` enum; reads the current level from in-memory state up-front to set the + `Previous-Account-Level` / `New-Account-Level` audit trailers; transacts with + `Action: account-level.change`; returns the updated person (200). + +## Validation + +- [x] anon → 401; regular user → 403; staff (non-admin) → 403; admin → 200. +- [x] invalid `level` → 422 (schema validation — this app maps Fastify + validation errors to 422, not 400; spec + test aligned to that). +- [x] promote user→staff, demote staff→user reflected in the response. +- [x] idempotent no-op (same level) → 200. +- [x] last-administrator self-demotion → 422; demoting an admin while a second + admin exists → 200. +- [x] audit trail: commit carries `Action: account-level.change` + + `Previous-Account-Level` + `New-Account-Level` trailers (asserted by + reading the bare repo's HEAD commit). +- [x] `type-check` + `lint` clean; people-account-level 10/10; full api suite green. + +## Notes + +- Sibling to the deactivate/reactivate/purge admin verbs ([person-deactivate-purge](person-deactivate-purge.md)). +- The spec originally documented `400` for a bad body; corrected to `422` to + match the codebase's established schema-validation mapping (caught by the test). + +## Follow-ups + +- `POST /api/people/:slug/impersonate` remains explicitly deferred (noted in the + spec) — admin tooling can grow into it later. diff --git a/specs/api/people.md b/specs/api/people.md index 4145610..dfe707f 100644 --- a/specs/api/people.md +++ b/specs/api/people.md @@ -16,6 +16,7 @@ See [data-model.md](../data-model.md#person). | `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). | ## GET /api/people @@ -221,7 +222,36 @@ When a deactivated person is referenced in a serialized response (e.g. project m 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`. -## Staff-only sub-endpoints (deferred to staff specs) +## POST /api/people/:slug/account-level + +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. + +### Request body + +```json +{ "level": "staff" } +``` + +`level` is one of `user` | `staff` | `administrator`. Setting the person's current level is an idempotent no-op (still 200). + +### Response — 200 + +Returns the updated person (same shape as `GET /api/people/:slug`), so the caller sees the new `accountLevel`. + +### Audit trail + +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. + +### Last-administrator guard + +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. + +### Errors + +- `403 forbidden` — caller is not an administrator +- `404 not_found` — slug doesn't exist +- `422 validation_failed` — `level` missing / not one of the three enum values (schema validation), or the change would demote the last administrator + +## Deferred admin sub-endpoints -- `POST /api/people/:slug/account-level` — change `accountLevel` (admin-only). Body: `{ "level": "staff" }`. Audit-logged. - `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.