Skip to content

Platform role

Agentfy has two different kinds of administrator, and telling them apart is the whole point of this page.

  • A team administrator runs one team. That is UserTeam.role, and its top value is admin.
  • A platform administrator may look across all teams. That is User.platformRole, and its only value is also admin.

They are spelled the same and they mean entirely different things. Confusing them would give whoever runs a single team sight of everyone else's — and nothing in any response would show it, because the request would simply succeed. Everything below exists to make that mistake hard to make.

The role

Where it livesUser.platformRole — a nullable column
Valuesnull (everyone) or 'admin'
LevelsOne. A second is cheap to add later and expensive to withdraw
Read fromThe database, on every request — never from the token

Because it is read per request rather than carried in the JWT, revoking the role takes effect immediately instead of when the token expires. The window in which a former administrator can still read every tenant is the one window that must not exist.

The founder — the one grant nobody authorises

A fresh installation would otherwise be a locked door with the key inside: the panel is invisible to everyone because there is no administrator, and the only way to make one is a shell on the database. So the earliest account becomes the platform administrator, once.

Read the word once strictly, because the tempting version of this rule is the dangerous one. "If there are no administrators, promote the oldest account" reads harmlessly while the system is new and never expires — revoke the last administrator on a thousand-user install and it hands the role to whoever registered first, with no bug anywhere.

So the condition is not "is anybody an administrator right now". It is a row that records that this installation has already had its founder. Nothing deletes it, and deleting the founder's account does not take it either, because it holds their address as text rather than as a relation. Undoing it means deliberately deleting a row from a table whose only content is the statement that the door is closed.

  • Two doors, one decision. It is checked when the api starts — which covers a database that predates this code and where nobody may register for weeks — and again when an account is created, which covers the genuinely fresh install where waiting for the next restart would mean the founder registers, sees no panel, and has to restart the server they just started.
  • If an administrator already exists, the installation is recorded as founded and nobody is promoted.
  • Exactly one, under concurrency. Two simultaneous registrations cannot both found the installation; the database decides, not a check-then-write.
  • It is loud. The grant happens with nobody present to authorise it, so the only thing left is the trace: a log line at ordinary level saying who was promoted and on what grounds.

Granting it — every one after the founder

There is no endpoint and no button — do not look for one.

bash
cd api
node scripts/platformAdmin.mjs list
node scripts/platformAdmin.mjs grant  someone@example.com
node scripts/platformAdmin.mjs revoke someone@example.com

Granting sight of every tenant is the most privileged act in the product, and the first act that deserves an audit log is the one that hands out the privilege. The audit log does not exist yet, so the act stays where it is already accounted for: a shell someone had to be trusted with, on a database someone had to be able to reach. Building the button first would mean building the most dangerous action in the product at the one moment nothing could record it.

Nothing reachable over HTTP can set the role: it is absent from the create and update types, so no request body carries it, and absent from UserDto, so no response reveals it.

How administrative routes are protected

PlatformAdminGuard is registered once, as a global guard, in AdminGroupModule. Controllers do not write @UseGuards.

A route is administrative when its declared address begins with admin/ — regardless of which decorator carries that first segment. Nest composes the address out of two declarations, and the guard reads both:

ts
@Controller('admin/team')      //  protected — nothing else to write
export class AdminTeamController {
  @Get(':id') read() {}        //  → admin/team/:id
}

@Controller()                  //  protected too — the address is what counts
export class AdminAuditController {
  @Get('admin/audit') read() {}  //  → admin/audit
}

Only the segment that actually leads the address counts, and the controller's path leads it whenever it declares one. So admin further along is not a trigger, and must not be:

ts
@Controller('team')
export class TeamController {
  @Get('admin') admins() {}    //  → team/admin — an ordinary product route
}

The trigger is the declared address rather than a decorator, because a decorator can be forgotten and a forgotten authorisation check is invisible — the endpoint just works. An address is the one thing an administrative endpoint cannot help declaring, so there is nothing to remember and therefore nothing to leave out.

The guard reads the declared address, not the request URL. A global prefix, a mount point or a proxy rewrite can put segments in front of /admin in a URL; none of them can move what an author typed into the decorators. By the same token, no spelling of the URL gets round it — uppercase, a trailing slash and a nested segment all reach the same declaration and meet the same refusal.

Two exceptions:

  • @PlatformAdmin() marks an administrative route that has to live outside the prefix.
  • @Public() is honoured. That is the sign-in door: it must be reachable by someone who is nobody yet, and it refuses non-administrators itself, before issuing a token, using the same predicate.

Answers a caller gets

SituationResponse
No token, or an expired one401
A valid token, but not a platform administrator403 NOT_PLATFORM_ADMIN
A platform administrator200

The two codes carry different instructions and an admin console acts on the difference: 401 is worth retrying with a fresh token, 403 never is — a new token will say exactly the same thing.

What an administrator may see

Everything except the content of conversations. Teams, members, agents, counts, dates and statuses are visible; the bodies of chat messages and the contents of agent memory are not.

Widening this later costs a change; narrowing it cannot undo what has been read. So an overview screen may show that a conversation holds forty messages and when the last one arrived — and may not show what any of them said.

Checking your own standing

GET /admin/access

Returns the caller's user id, address and platform role. It takes no action — this is the grounds for access and nothing more. Actions arrive together with the audit log that has to record them.

For the code

ts
import { isPlatformAdmin, PlatformRoleTypes } from '#user/user/domain';

isPlatformAdmin is the single answer to the question, and every caller uses it rather than comparing strings. The route guard calls it; so does the admin sign-in, which cannot use a guard at all because it must refuse before it mints a token. One decision, two doors.