Keyring
ReferenceControl-plane API

Members and invitations

Who may act in a workspace, and how they are invited.

Generated from 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 a krses_ dashboard session, as Authorization: Bearer.

PATCH /v1/members/:userId

Answers 200 on success.

Authentication.

  • A krsk_ secret key or a krses_ dashboard session, as Authorization: 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 a krses_ dashboard session, as Authorization: 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 a krses_ dashboard session, as Authorization: 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 a krses_ dashboard session, as Authorization: Bearer.

POST /v1/invites/:id/revoke

Answers 200 on success.

Authentication.

  • A krsk_ secret key or a krses_ dashboard session, as Authorization: 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) });

On this page