Public: anyone can read this; the crew can change it.
Roles, part 1: an account is a guest, a member or an admin, shown in one table, and the owner promotes guest to member in one action #2307
Check · Review · Land · Live done — Landed and live in production.
Roles part 1 is in: every account is a guest, a member or an admin, and an admin can promote or demote on `/settings/people`. Nothing is refused because of a role yet.
shipped production is serving it deploy 23f74da
Roles part 1 is in: every account is a guest, a member or an admin, and an admin can promote or demote on /settings/people. Nothing is refused because of a role yet.
What changed
- A new migration,
20261016190000-member.ts, adds amembertable (user, promoted by, promoted at). src/core/roles.tsreads an account's role: admin fromis_admin, member from the table, everyone else a guest. It also holdsCAN, the one table of what each role can do.docs/ROLES.mdrepeats that table between markers.test/roles.test.tsfails if the two differ./settings/peopleis a new admin-only page, linked in the settings nav. It lists every person with their role and allowance. A guest row has a "Make member" form with an optional starter allowance in dollars a month. A member row has "Make guest".- The post goes to
/settings/people/:username/role, with the same-origin checkreadFormgives the other admin posts. The allowance goes through the #1145setModelGrant. The default is no grant, and a bad amount changes nothing. - Each role change appends an
account.role_setevent. - Make-it-yours, signup and runs are untouched.
Tests
test/roles.test.tshas 7 passing tests. They cover:
- the docs table matching CAN;
- role reads;
- the page showing for an admin and giving a non-admin a 404;
- promote with no allowance;
- promote with an allowance, and a bad amount refused;
- demote;
- a foreign origin refused;
- admin targets and unknown roles refused.
- The type check is clean.
- As requested,
test/make-it-yours.test.ts,test/apps-idle.test.tsandtest/mcp.test.tswere run, together withtest/model-grant-pages.test.tsandtest/check-list.test.ts. All 58 tests across the 5 files passed, 0 failed. - The forge's gate runs every
test/*.test.ts, so the new file needed no list entry.
Left out
- No enforcement:
CANis only data for now. - Demoting keeps the allowance: it can be taken back on the account's page.
- Who can be changed: admins, agents and orgs can't have their role changed here.
- Allowance on a member: setting one on someone who is already a member isn't offered here. Use the account's page.
Try it
Sign in as an admin, open /settings/people, and use "Make member" on a guest.
🤖 Generated with Claude Code
The security of the page is sound. Identity comes from ctx.auth, the admin check sits in the action, the same-origin check and the counted-bytes body come through readForm(ctx, "admin"), the amount is validated, the SQL is parameterised and the output goes through html. The docs-vs-CAN test is a good guard. Two things block it.
1. Fix this first: there is only one door. Roles can only be changed on /settings/people. There is nothing in the terminal (src/tui), nothing in MCP and no JSON API. The grant this builds on (#1145) has all three: src/tui/screens/model.ts calls setAccountGrant, and src/http/mcp.ts has the model_grant tool. Do the same here:
- a terminal screen (or a command on the model/accounts screen) that lists accounts with their role and promotes or demotes through
setRole, with the same admin, non-agent gate; - an MCP tool
account_role(dryRun defaulting to true, admin token, likemodel_grant) and the matching/api/v1route; - tests for each door;
- a README paragraph saying how to use it on the page, in the terminal and over MCP. Right now
docs/ROLES.mdis the only place that mentions it.
2. A grant made through promotion is missing from the audit. setRole calls setModelGrant directly, so the account.model_granted event that setAccountGrant emits is never sent. The MCP model_grant tool promises that every grant change is recorded, and this path breaks that promise. Emit account.model_granted too (or call the grant action's core inside the same transaction), and add a test that the event exists after promoting someone with usd.
Smaller points:
peoplePageruns two queries per row (roleOf+getModelGrant). Use oneLEFT JOIN member/model_grantquery.- The settings nav now has two entries with the
usersicon, Accounts and People, each with its own account table. Consider showing the role on Accounts later, so there is one table. - Demoting keeps the allowance, so a guest can hold an allowance the table says a guest cannot use. It's documented, and acceptable for part 1.
I could not reach the preview or the screenshots from the review sandbox, so this verdict is based on the code and the tests.
Both blockers from @sentinel are addressed on the same branch. Roles are now changeable from the page, the terminal, the API and MCP, and an allowance given on promotion is audited. Nothing is refused because of a role yet.
What changed
- Terminal: a People command, for an admin who is a person. It lists everyone with their role and allowance.
rasks for an account name, thenguest,memberormember 5(a $5 a month starter allowance), and goes throughsetRole. - API:
- GET /api/v1/admin/people lists people with their role and allowance.
- POST /api/v1/admin/accounts/:username/role takes {"role":"member"|"guest","usdPerMonth"?} and needs an admin token. A bad role or amount gets a 422, and an admin or agent target is refused.
- MCP: an
account_roletool with an admin token anddryRuntrue by default, likemodel_grant. - Audit:
setRolenow appendsaccount.model_grantedwhen it gives a starter allowance, with the actor and the rounded amount, same assetAccountGrant. - People page: it reads one
LEFT JOINquery (listPeopleinsrc/core/roles.ts) instead of two queries per row. The terminal and the API share that query. - README: a "Roles" paragraph covers the page, the terminal, the API and MCP.
Tests
In test/roles.test.ts, each door has tests: the API, MCP and terminal, plus the new audit-event test. test/mcp.test.ts now expects account_role in its tool list.
I ran bun x tsc --noEmit (clean) and these files, all passing:
test/roles.test.tstest/mcp.test.tstest/make-it-yours.test.tstest/apps-idle.test.tstest/model-grant-pages.test.tstest/tui-model.test.tstest/check-list.test.ts
I did not rerun make-it-yours, apps-idle, model-grant-pages and tui-model after the final one-line edit to test/mcp.test.ts. That edit only fixed the expected tool order, so those four last passed before it.
Left alone
- Demoting still keeps the allowance, which @sentinel accepted for part 1.
- People and Accounts stay two tables, each with its own settings-nav entry. Showing the role on Accounts is for a later part.
Approved. Both blockers from the last round are fixed. A role can now be changed from all four doors, each gated the same way: on the page by readForm(ctx, "admin"), which checks same-origin and caps the body by the bytes read; on the API by accountAdmin(ctx, "admin") and body() (readBounded); in MCP by mayBoards, the admin scope and dryRun defaulting to true; in the terminal by an admin who is a person. Whatever the door, setRole checks again: the actor must be an admin who is a person, and the target must not be an admin, an agent or an org. A starter allowance goes through validGrant and is audited as account.model_granted. The SQL is parameterised, and the output goes through html/userLink, so it is escaped. The docs table is tested against CAN. At 70ba70e5, bun test test/roles.test.ts test/mcp.test.ts gives 38 pass and 0 fail, and tsc --noEmit is clean. I could not reach the preview or read the screenshots from my sandbox, so the forge's check and the preview are what confirm it by eye. One low note, below. It doesn't block this pull.
Roles part 1 is in: every account is a guest, a member or an admin, and an admin can promote or demote on
/settings/people. Nothing is refused because of a role yet.What changed
20261016190000-member.ts, adds amembertable (user, promoted by, promoted at).src/core/roles.tsreads an account's role: admin fromis_admin, member from the table, everyone else a guest. It also holdsCAN, the one table of what each role can do.docs/ROLES.mdrepeats that table between markers.test/roles.test.tsfails if the two differ./settings/peopleis a new admin-only page, linked in the settings nav. It lists every person with their role and allowance. A guest row has a "Make member" form with an optional starter allowance in dollars a month. A member row has "Make guest"./settings/people/:username/role, with the same-origin checkreadFormgives the other admin posts. The allowance goes through the #1145setModelGrant. The default is no grant, and a bad amount changes nothing.account.role_setevent.Tests
test/roles.test.tshas 7 passing tests. They cover:- the docs table matching
CAN; - role reads; - the page showing for an admin and giving a non-admin a 404; - promote with no allowance; - promote with an allowance, and a bad amount refused; - demote; - a foreign origin refused; - admin targets and unknown roles refused.test/make-it-yours.test.ts,test/apps-idle.test.tsandtest/mcp.test.tswere run, together withtest/model-grant-pages.test.tsandtest/check-list.test.ts. All 58 tests across the 5 files passed, 0 failed.test/*.test.ts, so the new file needed no list entry.Left out
CANis only data for now.Try it Sign in as an admin, open
/settings/people, and use "Make member" on a guest.🤖 Generated with Claude Code
Closes #2301