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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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 a member table (user, promoted by, promoted at).
  • src/core/roles.ts reads an account's role: admin from is_admin, member from the table, everyone else a guest. It also holds CAN, the one table of what each role can do.
  • docs/ROLES.md repeats that table between markers. test/roles.test.ts fails if the two differ.
  • /settings/people is 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 check readForm gives the other admin posts. The allowance goes through the #1145 setModelGrant. The default is no grant, and a bad amount changes nothing.
  • Each role change appends an account.role_set event.
  • Make-it-yours, signup and runs are untouched.

Tests

  • test/roles.test.ts has 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.ts and test/mcp.test.ts were run, together with test/model-grant-pages.test.ts and test/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: CAN is 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

Closes #2301

commented

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 a member table (user, promoted by, promoted at).
  • src/core/roles.ts reads an account's role: admin from is_admin, member from the table, everyone else a guest. It also holds CAN, the one table of what each role can do.
  • docs/ROLES.md repeats that table between markers. test/roles.test.ts fails if the two differ.
  • /settings/people is 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 check readForm gives the other admin posts. The allowance goes through the #1145 setModelGrant. The default is no grant, and a bad amount changes nothing.
  • Each role change appends an account.role_set event.
  • Make-it-yours, signup and runs are untouched.

Tests

  • test/roles.test.ts has 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.ts and test/mcp.test.ts were run, together with test/model-grant-pages.test.ts and test/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: CAN is 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

Changes requested — before the latest push

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, like model_grant) and the matching /api/v1 route;
  • 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.md is 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:

  • peoplePage runs two queries per row (roleOf + getModelGrant). Use one LEFT JOIN member / model_grant query.
  • The settings nav now has two entries with the users icon, 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.

commented

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. r asks for an account name, then guest, member or member 5 (a $5 a month starter allowance), and goes through setRole.
  • 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_role tool with an admin token and dryRun true by default, like model_grant.
  • Audit: setRole now appends account.model_granted when it gives a starter allowance, with the actor and the rounded amount, same as setAccountGrant.
  • People page: it reads one LEFT JOIN query (listPeople in src/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.ts
  • test/mcp.test.ts
  • test/make-it-yours.test.ts
  • test/apps-idle.test.ts
  • test/model-grant-pages.test.ts
  • test/tui-model.test.ts
  • test/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

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs