Skip to content

Commit 23f3522

Browse files
Merge pull request #147 from CodeForPhilly/feat/person-account-level
feat(person): admin account-level endpoint (#33)
2 parents 3ee3c2b + 43b48fe commit 23f3522

5 files changed

Lines changed: 364 additions & 2 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* POST /api/people/:slug/deactivate
77
* POST /api/people/:slug/reactivate
88
* POST /api/people/:slug/purge
9+
* POST /api/people/:slug/account-level (administrator)
910
* PATCH /api/people/:slug/newsletter (private-only mutation)
1011
*/
1112
import type { FastifyInstance } from 'fastify';
@@ -238,6 +239,51 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
238239
return reply.code(204).send();
239240
});
240241

242+
// POST /api/people/:slug/account-level (administrator only)
243+
// Spec: specs/api/people.md → POST /api/people/:slug/account-level
244+
fastify.post('/api/people/:slug/account-level', {
245+
schema: {
246+
tags: ['people'],
247+
summary: "Change a person's account level (admin only)",
248+
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
249+
body: {
250+
type: 'object',
251+
properties: { level: { type: 'string', enum: ['user', 'staff', 'administrator'] } },
252+
required: ['level'],
253+
additionalProperties: false,
254+
},
255+
},
256+
}, async (request) => {
257+
const { slug } = request.params as { slug: string };
258+
const { level } = request.body as { level: Person['accountLevel'] };
259+
260+
// Resolve the current level up-front so the audit trailers capture the
261+
// before/after. Reads in-memory state, which is current under the write
262+
// mutex; if the slug is unknown, setAccountLevel below 404s.
263+
const existingId = fastify.inMemoryState.personIdBySlug.get(slug);
264+
const previousLevel = existingId
265+
? fastify.inMemoryState.people.get(existingId)?.accountLevel
266+
: undefined;
267+
268+
const result = await fastify.store.transact(
269+
buildTransactionOptions({
270+
request,
271+
action: 'account-level.change',
272+
subjectType: 'person',
273+
subjectSlug: slug,
274+
responseCode: 200,
275+
extraTrailers: {
276+
'Previous-Account-Level': previousLevel ?? 'unknown',
277+
'New-Account-Level': level,
278+
},
279+
}),
280+
async (tx) => fastify.services.peopleWrite.setAccountLevel(tx, slug, level, request.session),
281+
);
282+
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
283+
const caller = getCallerSession(request);
284+
return ok(await fastify.services.people.get(result.value.person.slug, caller));
285+
});
286+
241287
// PATCH /api/people/:slug/newsletter (private-store only — no public commit)
242288
fastify.patch('/api/people/:slug/newsletter', {
243289
schema: {

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,60 @@ export class PersonWriteService {
354354
return { stateApply };
355355
}
356356

357+
/**
358+
* Set a person's accountLevel — administrator only. This is the sole path
359+
* for changing accountLevel (never via the generic PATCH), so the privilege
360+
* change is explicit and audit-logged. Rejects demoting the last
361+
* administrator with a 422 to avoid locking everyone out.
362+
* Spec: specs/api/people.md → POST /api/people/:slug/account-level
363+
*/
364+
async setAccountLevel(
365+
tx: DualStoreTx,
366+
slug: string,
367+
level: Person['accountLevel'],
368+
session: SessionContext,
369+
): Promise<{ person: Person; previousLevel: Person['accountLevel']; stateApply: StateApply }> {
370+
requireAuth('administrator', { session });
371+
372+
const id = this.#state.personIdBySlug.get(slug);
373+
if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`);
374+
const existing = this.#state.people.get(id);
375+
if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`);
376+
377+
const previousLevel = existing.accountLevel;
378+
379+
// Idempotent: setting the current level is a no-op (no commit).
380+
if (previousLevel === level) {
381+
return { person: existing, previousLevel, stateApply: new StateApply() };
382+
}
383+
384+
// Last-administrator guard: never let the count of administrators reach
385+
// zero, or admin operations become unreachable for everyone (this covers
386+
// an admin demoting themselves while sole administrator).
387+
if (previousLevel === 'administrator' && level !== 'administrator') {
388+
let adminCount = 0;
389+
for (const p of this.#state.people.values()) {
390+
if (p.accountLevel === 'administrator') adminCount += 1;
391+
}
392+
if (adminCount <= 1) {
393+
throw new ApiValidationError(
394+
'Cannot demote the last administrator — at least one administrator must remain.',
395+
{ level: 'last_administrator' },
396+
);
397+
}
398+
}
399+
400+
const updated: Person = PersonSchema.parse({
401+
...existing,
402+
accountLevel: level,
403+
updatedAt: nowIso(),
404+
});
405+
406+
await tx.public.people.upsert(updated);
407+
const stateApply = new StateApply().upsertPerson(updated);
408+
return { person: updated, previousLevel, stateApply };
409+
}
410+
357411
async updateNewsletter(
358412
slug: string,
359413
optedIn: boolean,
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* Tests for POST /api/people/:slug/account-level (administrator-only).
3+
*
4+
* Spec: specs/api/people.md → POST /api/people/:slug/account-level
5+
*
6+
* Covers:
7+
* - anonymous → 401
8+
* - regular user caller → 403
9+
* - staff (non-admin) caller → 403
10+
* - invalid level → 422 (schema validation)
11+
* - admin promotes user → 200, level reflected
12+
* - admin demotes staff → 200
13+
* - idempotent no-op (same level) → 200
14+
* - last-administrator self-demotion → 422
15+
* - demoting an admin while another admin exists → 200
16+
* - audit trail: commit carries Action + Previous/New-Account-Level trailers
17+
*/
18+
import { execFileSync } from 'node:child_process';
19+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
20+
import type { FastifyInstance } from 'fastify';
21+
22+
import { buildApp } from '../src/app.js';
23+
import { mintSessionFor } from '../src/auth/issue.js';
24+
import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';
25+
import { seedRawToml } from './helpers/seed-fixtures.js';
26+
27+
const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!';
28+
29+
const IDS = {
30+
alice: '01951a3d-0000-7000-8000-a0000000cafe',
31+
bob: '01951a3d-0000-7000-8000-b0000000cafe',
32+
staff: '01951a3d-0000-7000-8000-c0000000cafe',
33+
admin: '01951a3d-0000-7000-8000-d0000000cafe',
34+
};
35+
36+
async function mintCookies(
37+
personId: string,
38+
level: 'user' | 'staff' | 'administrator' = 'user',
39+
): Promise<string> {
40+
const { accessToken } = await mintSessionFor(personId, level, JWT_KEY);
41+
return `cfp_session=${accessToken}`;
42+
}
43+
44+
interface PersonDetailResponse {
45+
success: boolean;
46+
data: { slug: string; accountLevel?: string };
47+
}
48+
49+
describe('POST /api/people/:slug/account-level', () => {
50+
let dataRepo: { path: string; cleanup: () => Promise<void> };
51+
let privateStore: { path: string; cleanup: () => Promise<void> };
52+
let app: FastifyInstance;
53+
54+
beforeAll(async () => {
55+
dataRepo = await createFullDataRepo();
56+
privateStore = await createPrivateStorageDir();
57+
58+
for (const [slug, id, level] of [
59+
['alice', IDS.alice, 'user'],
60+
['bob', IDS.bob, 'user'],
61+
['staff-user', IDS.staff, 'staff'],
62+
['admin-user', IDS.admin, 'administrator'],
63+
] as const) {
64+
const toml = [
65+
`id = "${id}"`,
66+
`slug = "${slug}"`,
67+
`fullName = "Test ${slug}"`,
68+
`accountLevel = "${level}"`,
69+
`createdAt = "2026-05-01T00:00:00Z"`,
70+
`updatedAt = "2026-05-01T00:00:00Z"`,
71+
].join('\n');
72+
await seedRawToml(dataRepo.path, `people/${slug}.toml`, toml, `seed ${slug}`);
73+
}
74+
75+
app = await buildApp({
76+
serverOptions: { logger: false },
77+
overrideEnv: {
78+
CFP_DATA_REPO_PATH: dataRepo.path,
79+
STORAGE_BACKEND: 'filesystem',
80+
CFP_PRIVATE_STORAGE_PATH: privateStore.path,
81+
CFP_JWT_SIGNING_KEY: JWT_KEY,
82+
NODE_ENV: 'test',
83+
},
84+
});
85+
}, 60_000);
86+
87+
afterAll(async () => {
88+
await app.close();
89+
await dataRepo.cleanup();
90+
await privateStore.cleanup();
91+
});
92+
93+
const post = (slug: string, body: unknown, cookie?: string) =>
94+
app.inject({
95+
method: 'POST',
96+
url: `/api/people/${slug}/account-level`,
97+
headers: cookie ? { cookie } : {},
98+
payload: body as object,
99+
});
100+
101+
it('anonymous → 401', async () => {
102+
const res = await post('alice', { level: 'staff' });
103+
expect(res.statusCode).toBe(401);
104+
});
105+
106+
it('regular user caller → 403', async () => {
107+
const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.bob));
108+
expect(res.statusCode).toBe(403);
109+
});
110+
111+
it('staff (non-admin) caller → 403', async () => {
112+
const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.staff, 'staff'));
113+
expect(res.statusCode).toBe(403);
114+
});
115+
116+
it('invalid level → 422 (schema validation)', async () => {
117+
const res = await post('alice', { level: 'superuser' }, await mintCookies(IDS.admin, 'administrator'));
118+
expect(res.statusCode).toBe(422);
119+
});
120+
121+
it('admin promotes a user to staff → 200, level reflected', async () => {
122+
const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator'));
123+
expect(res.statusCode).toBe(200);
124+
const body = res.json<PersonDetailResponse>();
125+
expect(body.success).toBe(true);
126+
// accountLevel is visible to the admin (staff-level) caller.
127+
expect(body.data.accountLevel).toBe('staff');
128+
});
129+
130+
it('admin demotes staff to user → 200', async () => {
131+
const res = await post('staff-user', { level: 'user' }, await mintCookies(IDS.admin, 'administrator'));
132+
expect(res.statusCode).toBe(200);
133+
expect(res.json<PersonDetailResponse>().data.accountLevel).toBe('user');
134+
});
135+
136+
it('idempotent: setting the same level → 200 no-op', async () => {
137+
// alice is now 'staff' from the promote test.
138+
const res = await post('alice', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator'));
139+
expect(res.statusCode).toBe(200);
140+
expect(res.json<PersonDetailResponse>().data.accountLevel).toBe('staff');
141+
});
142+
143+
it('last administrator self-demotion → 422', async () => {
144+
// admin-user is the sole administrator.
145+
const res = await post('admin-user', { level: 'user' }, await mintCookies(IDS.admin, 'administrator'));
146+
expect(res.statusCode).toBe(422);
147+
});
148+
149+
it('can demote an administrator when another administrator exists → 200', async () => {
150+
const adminCookie = await mintCookies(IDS.admin, 'administrator');
151+
// Promote bob to administrator first (now two admins).
152+
const promote = await post('bob', { level: 'administrator' }, adminCookie);
153+
expect(promote.statusCode).toBe(200);
154+
// Now admin-user can be demoted — bob remains.
155+
const demote = await post('admin-user', { level: 'staff' }, adminCookie);
156+
expect(demote.statusCode).toBe(200);
157+
expect(demote.json<PersonDetailResponse>().data.accountLevel).toBe('staff');
158+
});
159+
160+
it('audit trail: commit carries Action + Previous/New-Account-Level trailers', async () => {
161+
// Make a fresh change and inspect the resulting commit on the bare repo.
162+
await post('staff-user', { level: 'staff' }, await mintCookies(IDS.admin, 'administrator'));
163+
const msg = execFileSync('git', ['-C', dataRepo.path, 'log', '-1', '--format=%B', 'main'], {
164+
encoding: 'utf8',
165+
});
166+
expect(msg).toContain('Action: account-level.change');
167+
expect(msg).toContain('Previous-Account-Level: user');
168+
expect(msg).toContain('New-Account-Level: staff');
169+
});
170+
});

‎plans/person-account-level.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
status: done
3+
depends: []
4+
specs:
5+
- specs/api/people.md
6+
issues:
7+
- 33
8+
pr: 147
9+
---
10+
11+
# Plan: person account-level change endpoint
12+
13+
## Scope
14+
15+
Deferred from write-api (#29): `accountLevel` was listed under People mutations
16+
but never shipped a dedicated endpoint. The spec called for it as the *only*
17+
way to change `accountLevel` (never via the generic `PATCH`), so the privilege
18+
change is explicit and audit-logged. Issue #33.
19+
20+
## Implements
21+
22+
- [api/people.md](../specs/api/people.md) — `POST /api/people/:slug/account-level`
23+
(administrator-only): request body, 200 response, audit trailers,
24+
last-administrator guard, error table. Promoted from a deferred stub to a
25+
full section.
26+
27+
## Approach
28+
29+
- **Write service** `PersonWriteService.setAccountLevel(tx, slug, level, session)`:
30+
`requireAuth('administrator')`; idempotent no-op when the level is unchanged;
31+
**last-administrator guard** — refuse to demote the only `administrator`
32+
(counts admins in state; `<= 1` → `ApiValidationError` → 422); returns the
33+
updated person + `previousLevel`.
34+
- **Route** `POST /api/people/:slug/account-level`: body schema validates the
35+
`level` enum; reads the current level from in-memory state up-front to set the
36+
`Previous-Account-Level` / `New-Account-Level` audit trailers; transacts with
37+
`Action: account-level.change`; returns the updated person (200).
38+
39+
## Validation
40+
41+
- [x] anon → 401; regular user → 403; staff (non-admin) → 403; admin → 200.
42+
- [x] invalid `level` → 422 (schema validation — this app maps Fastify
43+
validation errors to 422, not 400; spec + test aligned to that).
44+
- [x] promote user→staff, demote staff→user reflected in the response.
45+
- [x] idempotent no-op (same level) → 200.
46+
- [x] last-administrator self-demotion → 422; demoting an admin while a second
47+
admin exists → 200.
48+
- [x] audit trail: commit carries `Action: account-level.change` +
49+
`Previous-Account-Level` + `New-Account-Level` trailers (asserted by
50+
reading the bare repo's HEAD commit).
51+
- [x] `type-check` + `lint` clean; people-account-level 10/10; full api suite green.
52+
53+
## Notes
54+
55+
- Sibling to the deactivate/reactivate/purge admin verbs ([person-deactivate-purge](person-deactivate-purge.md)).
56+
- The spec originally documented `400` for a bad body; corrected to `422` to
57+
match the codebase's established schema-validation mapping (caught by the test).
58+
59+
## Follow-ups
60+
61+
- `POST /api/people/:slug/impersonate` remains explicitly deferred (noted in the
62+
spec) — admin tooling can grow into it later.

0 commit comments

Comments
 (0)