Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/api/src/routes/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -238,6 +239,51 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
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: {
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/services/person.write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
170 changes: 170 additions & 0 deletions apps/api/tests/people-account-level.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> };
let privateStore: { path: string; cleanup: () => Promise<void> };
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<PersonDetailResponse>();
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<PersonDetailResponse>().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<PersonDetailResponse>().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<PersonDetailResponse>().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');
});
});
62 changes: 62 additions & 0 deletions plans/person-account-level.md
Original file line number Diff line number Diff line change
@@ -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.
Loading