Workspaces, members and Clerk
Foliot keeps workspaces and who belongs to them in its own tables. Clerk holds no organizations here. It supplies accounts and sessions (people sign in to Foliot Console through it) and, when you give Foliot a secret key, sends the invitation emails. This page is the operator’s set-up: the optional webhook, and how a workspace gets its first owner. It needs an operator token and access to the Clerk dashboard.
Roles are managed in Foliot, never in Clerk. A person joins a workspace with the role the
invitation named. Nothing in a Clerk payload (a role such as org:admin, permissions, metadata)
is read as a role, and the webhook never adds anyone to a workspace or changes a role. An owner
changes roles inside Foliot (see “Managing members” below).
What the webhook does, and what it does not
The webhook is optional. It keeps the people Foliot already knows in step with their Clerk accounts, and nothing more:
user.updatedrefreshes the email and name of a person Foliot already has. It creates no one.user.deletedremoves the person and every membership they hold. If that was a workspace’s last owner, the workspace is left without one (see “A workspace with no owner”).- Everything else is acknowledged and ignored. That includes
user.created, and everyorganization.*andorganizationMembership.*event: Clerk organizations play no part in Foliot, so nothing is applied from them. One of them,organizationMembership.deleted, used to take a person out of a linked workspace, so it is logged at Warn when it arrives for a person Foliot has (see “Upgrading from the release that linked organizations” below). The answer is the same204as for an event that did something, nothing is written, and Svix has nothing to redeliver. - No other app’s people are stored. A Clerk instance can be shared by several apps, and their events arrive at this endpoint too. Foliot keeps none of their profiles, and nothing at all about an account that is deleted:
user.deletedremoves the person if Foliot has them, and for anyone else it writes nothing.
Steps
-
Create the endpoint in Clerk. In the Clerk dashboard, Webhooks, add an endpoint at
https://<your Foliot host>/webhooks/clerk. Subscribe to exactly these events and no others:user.updateduser.deleted
Foliot ignores every other event, but there is no reason to send it traffic it will discard. (If you set the endpoint up for an earlier version, you can unsubscribe it from the
organization.*andorganizationMembership.*events; leaving them subscribed changes nothing, apart from that Warn line for a removal of a person Foliot has.) -
Give Foliot the signing secret. Copy the endpoint’s signing secret (it starts with
whsec_) intoFOLIOT_CLERK_WEBHOOK_SECRETand restart the server. A value that is not a well-formed secret stops the boot, and the error does not repeat the value. Until it is set the route does not exist: it answers exactly as any other path this server does not serve.
Upgrading from the release that linked organizations
If a workspace was ever linked to a Clerk organization, the people who joined it through that organization are ordinary members now, and removing someone from the organization in Clerk no longer removes them from Foliot. Nothing else will tell you: the removal event is acknowledged with a 204 and, on its own, changes nothing.
Before deploying this release, list the workspaces whose clerk_org_id is set (SELECT slug FROM workspaces WHERE clerk_org_id IS NOT NULL) and keep the list: the release’s migration drops the clerk_org_id column when the server starts, and after that nothing records which workspaces were linked. Then, for each of those workspaces:
- List its members (
GET /workspaces/{id}/members) and decide who should still have access. - Remove the others in Foliot (
DELETE /workspaces/{id}/members/{userId}).
After that, manage people in Foliot only. Until the organization is unsubscribed, each membership removal it sends for a person Foliot has shows up as a clerk: an organization removal arrived for a person Foliot has, and was NOT applied line at Warn. The line carries Clerk’s opaque ids (clerkUserId, organizationId) and the query to find the person: SELECT id FROM users WHERE clerk_user_id = '<clerkUserId>'. A Clerk instance can be shared by several apps, so removals for people Foliot does not have are logged at Debug with the type only, and organization.deleted (it names no person) is not logged above Debug at all; the pre-deploy review above is what covers those.
Giving a workspace its first owner
An operator makes a workspace and names its first owner by email address:
curl -X POST "https://<your Foliot host>/workspaces" \
-H "Authorization: Bearer <operator token>" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme", "firstOwnerEmail": "owner@acme.example" }'
That records a pending invitation for the owner role; the person is let in as an owner the first time they sign in with that address verified (see “How an invitation works”). To give an existing workspace a new owner, send POST /workspaces/{workspaceId}/invitations with { "email": "...", "role": "owner" } and the operator token.
To find a workspace’s id, list them with an operator token: GET /workspaces answers every workspace with its id, slug, name, and how many members, owners and apps it holds.
Managing members
An owner of a workspace decides what each member may do. These calls take the owner’s own session, or an operator token; a member who is not an owner gets 403.
| Call | What it does |
|---|---|
GET /workspaces/{workspaceId}/members |
Lists the members: userId, clerkUserId, name and email where known, role, joinedAt. Paged by limit and cursor. |
PUT /workspaces/{workspaceId}/members/{userId}/role |
Sets the member’s role to owner, operator or viewer (body { "role": "..." }). |
DELETE /workspaces/{workspaceId}/members/{userId} |
Removes the member from the workspace. |
GET /workspaces/{workspaceId}/history |
Who changed whose role and who removed whom, newest first, with the roles before and after. Paged by limit and cursor. |
POST /workspaces/{workspaceId}/invitations |
Invites a person by email with a role (body { "email": "...", "role": "..." }). Answers 201 with the invitation, whether or not that address already has an account. See below. |
GET /workspaces/{workspaceId}/invitations |
Lists the pending invitations, with the invited addresses. Paged by limit and cursor. |
DELETE /workspaces/{workspaceId}/invitations/{invitationId} |
Cancels a pending invitation, and the address with it. The emailed link is revoked too, if no other workspace is waiting on the same address. |
userId is Foliot’s id for the person, taken from the list, not their Clerk id. A change applies to the member’s very next request.
How an invitation works
This needs FOLIOT_CLERK_SECRET_KEY on the server (see Configuration). Without it an invitation is only recorded: nothing is emailed and nobody is let in by it.
-
Someone with no account yet. Foliot records a pending invitation and has Clerk email the address an invitation to sign up. The invitation lasts 14 days. The person is added, with the role you chose, the first time they call
GET /meafter Clerk reports that address as verified for them, whatever else they already belong to. Foliot asks Clerk at most about every 15 seconds for a person with no workspace and every 10 minutes for one who has a workspace, so an invitation sent before or after their first sign-in is found, and one sent to a person who was added to another workspace first is found within about 10 minutes. A mirrored profile email, or an address in a token, never admits anyone: only an address Clerk says that person has verified. -
Someone who already has an account at that address. The call is the same, and answers with the same status and body: a pending invitation is recorded and nothing is emailed (Clerk would refuse an invitation for an address that has an account, and there is nobody to email who is not already signed up). Nobody is added by the call, and neither the status nor the body says whether an account exists. The time the call takes does: an address with an account needs fewer calls to Clerk, so someone who times it can tell. That is not hidden. The person is let in, with the role you chose, the next time they open Foliot Console: Foliot asks Clerk which addresses they have verified, exactly as for anyone else. They are not notified, because Clerk cannot email an existing user, so tell them.
-
Limits on sending. A workspace can send 30 invitations an hour, and three for the same address in a day, counting the ones it cancelled. Either answers
429before anything is sent or recorded. The place is taken before Clerk is asked to send, so a burst of requests at once cannot exceed it. Cancelling does not give a place back, because a cancelled invitation was still emailed; a call that fails because Clerk was down or refused the address does, because nothing was sent. The limit is counted from Foliot’s own record and for every address alike, so it never tells you whether an address has an account. -
If Clerk cannot be reached the call answers
503and nothing is recorded; try again. If the connection to Clerk could not be made at all, or Clerk answered that it is rate limiting, nothing was sent and the place in the limits is given back. If Clerk did not answer the request to send (a timeout, a gateway error), the message says an email may have been sent, because Clerk can finish the request after Foliot has stopped waiting: Foliot asks Clerk what it holds, adopts and records an invitation that was made, and otherwise tells you trying again will not send a second email. Those unanswered sends keep their place in the limits above. Once an email has gone out, the row is recorded even if you close the page, so trying again never makes a second email. An invitation is never recorded without its email having been asked for. -
Cancelling removes the pending invitation and revokes the emailed link, unless another workspace has also invited the same address (Clerk keeps one invitation per address, and it is revoked when the last one goes). The cancel is final first and the revoke follows it, so a Clerk that is slow or down cannot undo the cancel. An invitation that someone else sent (made in the Clerk dashboard, or by another application on the same Clerk instance) is used as it is and is never revoked by Foliot: only invitations Foliot made carry its marker. The cancel stands even if Clerk cannot be reached to revoke; the person could then still sign up to Clerk, but would join nothing.
-
The history records
invitation.sent,invitation.cancelledandinvitation.acceptedby invitation id, and the person who joined is the actor ofinvitation.accepted. It never holds the invited address. -
The role and remove calls never add anyone. People arrive by invitation (above), with the role the owner chose. Changing the role of someone who is not a member answers
404. -
A workspace’s only owner cannot be demoted or removed, by anyone, an operator token included: the call answers
409. Make someone else an owner first; after that an owner can step down or leave. -
Removing someone here leaves their Clerk account alone. They can still sign in; they see no workspace they are not in. To let them back, invite them again.
-
Role changes and removals are in the workspace history (
GET /workspaces/{workspaceId}/history, above), with who made them.
Making your own workspace
A signed-in person who belongs to no workspace makes their first one themselves: Foliot Console shows a “Create your workspace” form in place of every page, and POST /me/workspaces (body { "name": "...", "slug": "..." }, the slug optional and made from the name) makes the workspace and makes the caller its owner at once. There is nothing to invite and nothing to accept; the history starts with who made it.
- It is for a person who has never belonged to a workspace, and only that. A person who belongs to one, in any role, gets
409, and so does a person who was removed from one: Foliot records when a person first belonged to any workspace and never clears it. So offboarding someone means removing them from the workspace, and nothing more is needed to keep them from making their own. (People removed before this was recorded left no trace, so they read as “never”.) A further workspace, or one for somebody else, is the operator’s (POST /workspaces). GET /mesays whether a person may.canCreateWorkspaceistrueonly for a person who has never belonged to a workspace, and the console offers the form only then; a person who was removed from theirs sees “ask an owner to invite you” instead of a form that can never work.- An invitation comes first. Before anything is made, a person who has been invited to a workspace is let into it (
409, reload to see it), so an invitee never ends up owning a stray workspace they cannot leave. If Clerk cannot say whether they were invited, nothing is made (503) and trying again is safe. That check is limited to one per person every few seconds (429), and it comes last: a bad name, a taken name, or a person who has belonged to a workspace is refused first, from Foliot’s own records, without asking Clerk anything. An invitation sent in the instant between that check and the creation is not caught: the person is then in both, which an operator can untangle. - A person only. An operator token or an app key is
403: neither is a member of anything, so neither could own what it makes. - Who may sign in is not decided here. Foliot adds no condition of its own, such as an email domain. That gate is your Clerk instance’s: set its access mode to Invite-only and only the people you invite can sign up, and so only they can make a workspace.
- A taken name is
409too (the slug is made from the name), and costs the person nothing: they still have their one workspace to make.
A workspace with no owner
A workspace has no owner when its last owner’s account is deleted: user.deleted is applied (revoking access is never refused), and the server logs a warning naming the workspace. The foliot_workspaces_without_owner metric on /metrics counts every workspace that holds apps or members and has no owner, so you can alert on it. GET /workspaces?needsOwner=true (operator token) lists exactly the workspaces that metric counts. A workspace nothing has been put into yet (one you have just made, whose first owner has not signed in) is not counted. It is recoverable in two ways. If the workspace still has members, make one of them an owner with an operator token: PUT /workspaces/{workspaceId}/members/{userId}/role with { "role": "owner" }. Otherwise, or to name someone who is not a member yet, invite them as an owner with the operator token: POST /workspaces/{workspaceId}/invitations with { "email": "...", "role": "owner" }; they become the owner when they sign in with that address verified. That second route needs FOLIOT_CLERK_SECRET_KEY: without it Foliot cannot ask Clerk which addresses a person has verified, so the invitation is recorded and never matches. A production server cannot be running without the key (it refuses to start), but a development server can, and there the way back is the first route, promoting a member. Nothing is lost in the meantime; people who can already do things in the workspace keep doing them, and only owner-only actions (revealing or rotating secrets, updating an app, managing members) wait.
Tin Bee’s operators
Tin Bee runs Foliot, and its staff are the platform’s operators: people who sign in to Verge like anyone else and hold a platform role in Foliot’s own table (platform_members), read on every request and never put in a token. There are two: platform_owner and platform_operator.
- A platform role opens the
/platformroutes and nothing else. It is not a workspace role, and it gives no access to any workspace’s apps, runs, secrets or members. To work inside a customer’s workspace, staff need a membership in it like anyone else. - Everyone without one is told the routes are not there:
404, the same bytes for a person with no platform role, an operator token and an app key. Aplatform_operatoron an owner-only route gets403.
| Call | Who | What it does |
|---|---|---|
GET /platform/workspaces |
operator or owner | Every workspace with its member, owner and app counts: the same list GET /workspaces gives the operator token. |
GET /platform/operators |
operator or owner | Everyone with a platform role, and which. |
POST /platform/operators |
owner | Gives a person a platform role (body { "email": "...", "role": "platform_operator" }). |
PATCH /platform/operators/{userId} |
owner | Changes a platform role (body { "role": "..." }). |
DELETE /platform/operators/{userId} |
owner | Takes a person off the platform. Their account and workspace memberships are untouched. |
GET /platform/history |
owner | Who added whom, changed whose platform role, and removed whom, newest first. |
- Only a verified
@tinbee.comaddress qualifies. Exactly that domain (not a subdomain, nottinbee.com.example), and Clerk must say the person has verified it: Foliot asks withFOLIOT_CLERK_SECRET_KEYat the moment of the grant, and answers503(changing nothing) when the server has no key or Clerk does not answer. The person must have signed in to Verge once with that address on file. The check is made when the role is granted, not on every request: someone who leaves Tin Bee keeps their platform role until an owner removes it or their Clerk account is deleted. - The platform’s only owner cannot be demoted or removed (
409): make someone else a platform owner first. Every change is in the platform history, written in the same transaction.
The first owner (once, at deploy)
There is no command, environment variable or “first sign-up” rule that makes anyone a platform owner. The first one is added by hand, once, with SQL against the server’s database. The person must have signed in to Verge once, so their users row exists. Find their Clerk id:
SELECT id, clerk_user_id, email FROM users WHERE lower(email) = 'you@tinbee.com';
Check that exactly one row comes back and that it is the right person, then add them, with the history row, in one statement (put their clerk_user_id in place of user_xxx):
WITH added AS (
INSERT INTO platform_members (user_id, role, added_by)
SELECT id, 'platform_owner', 'bootstrap' FROM users WHERE clerk_user_id = 'user_xxx'
ON CONFLICT (user_id) DO NOTHING
RETURNING user_id
)
INSERT INTO platform_audit (action, actor, subject, role_to)
SELECT 'platform.member_added', 'bootstrap', 'user:' || u.clerk_user_id, 'platform_owner'
FROM added JOIN users u ON u.id = added.user_id;
INSERT 0 1 means it is done. INSERT 0 0 means nothing changed: the id names nobody (they have not signed in yet), or they already hold a platform role (SELECT * FROM platform_members). Running it again is harmless. From then on, add everyone else with POST /platform/operators.
If the platform’s last owner’s Clerk account is deleted, their platform role goes with it (revoking access is never refused), the server logs clerk: a deleted account was the platform's last owner at Warn, and nobody can manage the platform’s people. Run the statement above for another staff member. Workspaces and apps are not affected.
How a delivery is checked
Clerk delivers through Svix, which signs every request. Foliot verifies the signature against the raw request body before it parses the body or touches the database, accepts only a timestamp within five minutes of now, and reads at most 64 KiB. A delivery that cannot be verified gets one fixed 400 whatever was wrong (the reason goes to the server log, never the response). A verified event gets 204 whether or not it changed anything. A 5xx means the database work failed, or that Foliot could not read a user.deleted, the one event that takes access away; Clerk will deliver it again, and the server logs an error so a payload it does not understand is noticed rather than lost. A user.updated that cannot be read is acknowledged and logged, and an event of a type Foliot does not act on is acknowledged whatever it looks like.
Foliot keeps no record of a deleted Clerk account. (Earlier releases remembered the id for 30 days so that a late organization event could not bring the person back; those events are no longer handled, and the record was dropped with the clerk_deleted_users table.)