Members and invitations
Who may act in a workspace, and how they are invited.
packages/api/src/resources/members.controller.ts by packages/docs/tools/generate-reference.mjs. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.Who may act in this workspace.
Everything that changes the answer is owner only, and that is the whole
point of having a role above admin: an admin runs the product — projects,
tenants, keys, secret keys — and cannot give themselves a colleague, promote
themselves, or remove the person who could undo either.
A member never reaches a mutation here at all: PrincipalGuard refuses any
unsafe method from that role before a controller runs, so the owner checks
below are about the owner/admin split rather than about read-only access.
The role axis is not the only one. A membership carries no environment, which
puts it under env-guard.ts's third rule -- a test principal is read-only on
the resources that carry no environment at all -- and assertOwner returns
early for a machine principal by design, so without assertLiveCredential
these four mutations had no guard on either axis. A leaked krsk_test_ key
could invite itself as owner, remove the real owner and read the live secret
keys. That is the second time the third rule failed by being a list somebody
had to remember to extend; env-boundary.spec.ts now sweeps it from the
route table instead.
GET /v1/members
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
PATCH /v1/members/:userId
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. userId.
Body. Validated by this schema, from the control plane's own source:
const UpdateMember = z.object({ role: Role });const Role = z.enum(MEMBER_ROLES as [MemberRole, ...MemberRole[]]);DELETE /v1/members/:userId
Removing someone ends their access on their next request: the session guard re-reads this row every time, so there is no window in which a removed person keeps working.
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. userId.
POST /v1/invites
The token is <workspace_id>.<secret> and is returned once, in this
response. The first half is how the acceptance transaction knows which
workspace to scope itself to; the second half is the credential, and only
its SHA-256 is stored.
Returning it here is deliberate: an owner who is going to paste the link into Slack should not have to wait on our mail queue, and they already have every power the invitation confers.
It is not what gets emailed. The outbox holds a reference to this row and
no credential (migration 0012), so the week 8 sender mints its own link into
mail_lookup_hash; this one keeps working either way.
Answers 201 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Body. Validated by this schema, from the control plane's own source:
const CreateInvite = z.object({ email: z.email().max(320), role: Role });const Role = z.enum(MEMBER_ROLES as [MemberRole, ...MemberRole[]]);GET /v1/invites
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
POST /v1/invites/:id/revoke
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. id.
Accepting an invitation, which is the one membership write a non-member makes — so it hangs off the person plane rather than the workspace one.
The workspace comes out of the token's first half, and it is a scoping hint and not an authorisation: nothing is returned or written unless the SHA-256 of the whole token matches a live invitation inside that workspace. That is the same rule as everywhere else here — the workspace comes from the credential, never from a request parameter — with the credential being a link.
The signed-in person's address is deliberately not compared to the invitation's. Requiring a match would mean an account somebody registered under your address could claim your invitation, and would break the ordinary case of an invitation sent to a work address and accepted from a personal one. The token went to the inbox; whoever reads that inbox is who the owner meant.
What is required is that the accepting account's own address has been
proved (migration 0012, captain-settled). This is the single place the
verification gate sits, and the reason is GET /v1/audit_logs: joining a
workspace is the moment a self-declared address starts being printed as
actor.email in somebody else's audit log. Creating a workspace of your own
is not gated -- there is no other party to mislead -- so before week 8's mail
delivery exists a vendor's first person self-serves and their colleagues
cannot be added. Teams blocked, not product blocked; the dependency is in
packages/api/README.md.
The gate self-heals rather than needing anyone to remember it in week 8:
accepting through the link the sender minted is possession of the invited
mailbox, so an acceptance whose addresses match verifies the address on the
way through. The owner's own copy of the token proves nothing of the kind --
it came back to the owner in the POST /v1/invites response, out of band --
which is exactly why the two hashes stay distinguishable.
POST /v1/auth/invites/accept
Answers 200 on success.
Authentication.
- A
krses_dashboard session only.
Body. Validated by this schema, from the control plane's own source:
const AcceptInvite = z.object({ token: z.string().min(1).max(500) });