Team Members
TAP is multi-user. Owners and admins manage workspace configuration; members hold only the credentials assigned to them, and only the grants named on each one.
The role is called Member everywhere in the product. Its stored value is still approver — the API accepts and returns that string, so existing scripts keep working. Nothing else about the role changed with the rename.
Inviting a member
Go to Team in the dashboard sidebar and enter a colleague’s email address. Choose the role before sending the invite. TAP sends them an invitation link valid for 7 days.
If email delivery fails (e.g. RESEND_API_KEY not configured), the dashboard shows the accept URL so you can share it manually.
An invite can also carry three things beyond the role, set over the API when another product is doing the inviting:
credentials— the credentials to assign the moment the invite is accepted, each with its own use / approve / manage-policy bits, so the person sees their credentials on first login instead of waiting for someone to grant them one at a time.redirect_uri— where to send them once they finish joining. Validated at invite time against the origins registered for this deployment (TAP_LOGIN_ORIGINS, the same allowlist “Sign in with TAP” uses); anything else is a400.brand— the name and logo of the product doing the inviting, rendered on the join page. Falls back to TAP’s own branding.
See the API table for the exact shapes.
Accepting an invitation
The invite link opens one screen. It asks for a password (or Google), a verification code, and a passkey, in that order, and then takes the person straight to redirect_uri — or to the TAP dashboard when the invite named none. There is no sign-up-versus-log-in choice at any point: TAP already knows whether the invited address has an account, and takes that branch itself.
If the address already has a TAP account, that account is attached to the inviting team. A second account is never created. The person signs in with the credentials they already have, and lands in the invited team with it active.
If you are already signed in when the invite arrives, you do not need to log out. The dashboard applies any invite pending for your email when it loads, so a page refresh is enough — the new team then appears in the team switcher at the top of the sidebar. Your active team does not change; you stay in the team you were working in until you switch deliberately.
Every account has a personal workspace, so after your first completed sign-in the switcher is there for everyone. If you have accepted an invite and the invited team is not in it, the membership was not created — refresh once, and if it persists the invite may have expired (they last 7 days; ask for a Resend).
If someone signs up from the normal signup screen with a pending invite, the dashboard shows the invited team and lets them either join only that team or create their own workspace too.
Your personal workspace
Every account has a personal workspace, labelled Personal in the team switcher. It is created at your first completed sign-in (after your passkey, where TAP asks for one), never by a password alone or by signing up, and each creation is recorded in the audit log. If you belong to a company workspace you still land there when you sign in, and switch to Personal when you want it. A first sign-in through login.human.tech with no invitation, or signing in after being removed from your only workspace, lands you in your personal workspace instead of being refused as “not a member of any team”.
- Nobody can be invited into it. An invite sent from a personal workspace is refused with
403 personal_workspace. So it has no seats, it is not counted in any seat number, and it has no subscription or checkout. It is on the free plan, whose request limits still apply. - It is never a work box’s team. A box’s sign-in and token exchange stay bound to the box’s own workspace. A box cannot be registered to a personal workspace, and a box sign-in never falls back to one.
- It is not your default when you belong to a company workspace. Signing in lands you in a company workspace, and you switch to Personal when you want it. Only someone with no other workspace lands there.
- Its name is reserved. Internally it is named
personal-<account id>, and signup and operator provisioning refuse any workspace name starting withpersonal-(400 reserved_team_name).
All my credentials
All my credentials in the sidebar lists, for every workspace you belong to, the credentials you can use there: all of them in a workspace you own or administer, and in one where you are a Member, the ones you hold the Use grant on. It shows names and descriptions, never a value, and changes nothing. What an API key or a box reaches is unchanged: each still reaches only the credentials of its own workspace.
Moving a credential to another workspace
A credential added to the wrong workspace can be moved instead of deleted and added again. On Credentials, choose Move to… on its row and pick one of your other workspaces. The secret moves sealed: nobody sees it or types it again.
- Who can move it. An owner or admin of the workspace it is in. In your personal workspace, that is you. You must also belong to the workspace you move it to, in any role. Nobody there has to approve.
- Confirming. You confirm the move with your passkey, or with your authenticator app if you have no passkey.
- Who can use it afterwards. Only the owners and admins of the new workspace, until one of them gives it to an API key or a person. That includes you: if you are a member there rather than an owner or admin, you cannot use it after moving it. An Account key of the new workspace covers every credential there, so it covers this one too.
- What the old workspace loses, at once. Every API key, role and teammate given the credential there. Agents calling
<CREDENTIAL:name>with those keys are refused. Pending approvals on it, time-boxed approval grants, staged policy changes and OAuth reconnects in progress are cancelled, and an agent polling a cancelled approval is told the credential was moved. - Its policy. Its approval rules come along by default: methods, URLs, whether a passkey is required and how many approvals it needs. Approvers and approval chats never come along, because they belong to the old workspace. You can choose the new workspace’s default policy instead.
- Credentials that move money. When the credential requires a passkey to approve, is a signing key, or is bound to a payment API (Stripe, Mercury or Plaid), the people who could approve it in the old workspace are emailed that their approval rights ended. Their approval is not needed.
- Name clash. If the new workspace already has a credential with that name, the move is refused (
409 name_taken) and you pick another name in the same step. A move never replaces a credential. If you are an owner or admin there you are told before confirming, with a free name suggested. If you are only a member there, you are told only after confirming, with no suggestion, because you cannot see that workspace’s credentials. - Leftovers under the new name. Anything still pending in the new workspace for an earlier credential with the same name (approvals, staged policy changes, proposals, reconnects) is cancelled, so none of it applies to the moved credential.
- Audit. Each workspace gets one audit row naming who moved which credential, from where and to where.
A credential that belongs to one of your app’s end users cannot be moved.
Resending an invite
Expired invites stay listed on the Team page, grouped under Expired (each row carries expired on the API), with a Resend button. Resending issues a new link and emails it; the old link stops working immediately. This is the fix for the most common onboarding failure — a link found in a spam folder after it died. An expired invite holds no seat, so resending one has to fit in the plan.
An invite that has already been accepted cannot be resent (409 invite_already_accepted); remove the member instead if you need to start over.
Roles
| Role | Can do | Stored value |
|---|---|---|
| Owner | Everything. Cannot be removed. | owner |
| Admin | Manage credentials, agents, policies, notification channels, billing, and team members. Cannot grant owner access. | admin |
| Member | Only what their per-credential grants allow (below), plus managing their own profile, passkeys and API keys, and viewing team membership. | approver |
The first person to sign up for a workspace becomes the owner. Invites default to member. Members start with no credential access; assign credentials from the member row after they accept.
Passkeys
Every member signs in with a passkey — their face, fingerprint or a security key — and manages their own from Security → Your passkeys in the dashboard. The table lists each passkey with the name you gave it, when it was added, and when it was last used, and offers Rename and Remove.
Where it lives: Security is the last item in the dashboard sidebar, under Profile, whatever your role; your own row on the Team page and the Profile page both link to it. Resetting your own passkeys from the Team page is refused (400 reset_own_passkeys) — that action is for unlocking someone else.
Removing the last one is refused (400 last_passkey). Signing in requires a passkey, so an account with none is an account locked out of its own dashboard; add the replacement first, then remove the old one. If you are already locked out, an owner or admin can clear your passkeys from the Team page (“Reset passkeys”, itself passkey-gated and audited) and your next sign-in enrols a fresh one.
Renaming and removing are session-authenticated on the TAP origin and are not CORS-exposed — unlike the join endpoints, an embedding box can never drive them. Both write an audit row: tap:passkey-rename and tap:passkey-delete, carrying the credential id and the acting account, next to the existing tap:passkey-reset.
Why your browser says “tap.human.tech”
When you sign in, the browser offers passkeys saved for tap.human.tech, even when you started on your own company’s page. That is the WebAuthn RP ID — the identity your passkey is bound to — and TAP deliberately uses one for every deployment, so one passkey signs you in everywhere: your company’s box, the TAP dashboard, an approval link in a message. Giving each customer domain its own RP ID would mean a separate passkey per box, and re-enrolling everyone.
It cannot be changed after the fact either: the RP ID is part of what a passkey is signed against, so changing it would invalidate every passkey already registered.
Duplicates in the browser’s picker
A passkey registered before this release was created with a fresh WebAuthn user handle each time, and with the account’s internal id where its email should have been. Two consequences you may still see:
- The browser’s picker lists the same person several times, each row labelled with a UUID rather than an email. Each of those registrations was filed by the platform as a different account, which is also why the browser did not refuse the duplicates as it should have.
- Those entries keep the label they were registered with until they are registered again. TAP cannot rename an entry in your browser’s or password manager’s own store; renaming in “Your passkeys” changes TAP’s label, which is what the dashboard table shows, not what the picker prints.
New registrations are fixed at the source: the creation options now carry the account email as user.name, the person’s name (or the email) as user.displayName, and a stable per-account handle, so a second registration collapses onto the one entry instead of minting another. Every registration also sends excludeCredentials listing the account’s existing passkeys, so a browser asked for a duplicate on an authenticator that already holds one refuses outright.
To tidy up: open Security → Your passkeys, remove every entry you do not recognise (keeping one you can still use), then delete the leftover rows in your browser or password manager’s own passkey settings — Chrome under chrome://settings/passkeys, or in Google Password Manager if that is where they were saved. Register once more and the new entry shows your email.
Existing passkeys keep working throughout. Sign-in resolves an assertion by credential id, never by user handle, so a passkey registered under an old random handle verifies exactly as it did before — nothing is migrated and nothing needs to be.
Signing in on your company’s own page
Your work box can host sign-in itself: one button on its page, your passkey, and you are back in. It asks the browser “who is here?” without naming anyone first — which only works with a discoverable passkey, one the authenticator can offer on its own.
Passkeys registered before 2026-09-10 are not discoverable. TAP’s registration options did not ask for one, so your device filed them as answerable-only-when-named. Nothing is broken and nothing is lost — the browser simply has nothing to offer when the page asks the open question, and reports it as “no passkey found”.
Two things close the gap, and you need neither of them urgently:
- The box asks for your email once. When the open question finds nothing, the page asks for your address and TAP answers with every passkey on your account — the older ones included — so your existing passkey signs you in. The box remembers the address, so it is a one-time step on that device.
- Re-registering makes it discoverable. Open Security → Your passkeys, add a passkey, and remove the old entry once the new one works. Every registration since 2026-09-10 asks for a discoverable credential, on every path: the dashboard, the invitation wizard your company hosts, and a reset issued by an owner or admin.
A passkey created on a security key that cannot store discoverable credentials will still be refused at registration by the browser — that is the authenticator saying so, not TAP. Use a platform authenticator (Touch ID, Windows Hello, your phone) or a key with resident-credential support.
”That passkey belongs to another account”
Because every box shares the same RP ID, the browser offers every TAP account you hold, on every box. If you hold more than one — a work address and a personal one, say — the picker shows both and it is easy to pick the wrong one.
TAP now says so at the moment you pick it, rather than letting the sign-in finish and fail on the way back:
That passkey belongs to nanaknihal@gmail.com, which isn’t a member of Humanology and Health Sciences. Use the address your admin invited, or ask them to add this one.
Two ways forward, and either is fine: sign in again and pick the account your admin invited, or ask an owner or admin of that workspace to invite the address you just used. Nothing is wrong with the passkey itself — it is a perfectly good passkey for the account it belongs to.
The message carries a request id, which your box prints under it. Quote it if you ask for help: it names the exact audit row for that refusal.
Credential access
Owners and admins open Credential access from a member’s row on the Team page. Each credential you assign carries three independent grants:
| Grant | Field | What it allows |
|---|---|---|
| Use | can_use | Attach the credential to an API key they create, and reach it over MCP. Nothing else lets a member’s agent carry a credential. |
| Approve | can_approve | Decide approval requests on that credential — still narrowed by the policy’s allowed_approvers and min_approvals. |
| Policy | can_manage_policy | Read and change that one credential’s policy, confirmed with their passkey. It grants nothing else anywhere in the workspace. |
The three are separate choices. A member with only Approve reviews requests on a credential they can never carry themselves; a member with only Use cannot approve their own agent’s requests. Clearing all three removes the assignment.
Owners and admins hold all three grants on every credential implicitly — they have no assignment rows and never need one.
New assignments default to Use + Approve, which is exactly what an assignment meant before grants existed, so nothing changes for a team that ignores the toggles.
Policy management by a member
A member with Policy on a credential edits it through PUT /team/policies/{name} like a manager does, but the change is not applied by that request. TAP stages it and answers 202 with a txn_id; the member then confirms with their own passkey:
PUT /team/policies/{name} → 202 { "txn_id": "...", "requires_member_passkey": true }
POST /team/policy-changes/{txn_id}/passkey/begin
POST /team/policy-changes/{txn_id}/passkey/finish → { "applied": true }This is the same staged-change ceremony an end-user’s passkey-locked credential uses, for the same reason: a stolen dashboard session must not be able to loosen a policy on its own. The staged change is single-use and expires in 15 minutes, and the grant is re-checked when it is applied — revoking it mid-ceremony stops the change. Begin and finish may land on different proxy instances.
Applying a role’s credentials
To grant several credentials at once, use Apply credentials from a role in the same dialog: picking a role copies its current credentials into the grid with the default grants. This is a one-time snapshot, not a link — the member is granted the individual credentials, so editing the role afterward does not change what that member can reach. Review the grid and Save access to apply.
Credential access never reveals the raw secret value, and it never grants credential editing, role management, team-wide agent management, or team settings. Owners and admins still manage all API keys. Members see only API keys they created themselves, and those keys can use direct credential assignments only, not roles.
Removing a member
On the Team page, click Remove next to any non-owner member. The owner cannot be removed — transfer ownership by contacting support.
Removal ends everything that acts as that person in this workspace, on their next request:
- Dashboard sessions are invalidated.
- API keys they created here stop authenticating and are disabled. They stay disabled if the person is invited back; the person creates new ones.
- Apps and boxes they connected (MCP and OAuth connections, including work box sign-ins) stop working and can no longer refresh. If the person is invited back, they connect again.
Their keys and connections in any other workspace they belong to are not affected.
If this was their only workspace, they can still sign in to TAP: they land in their own personal workspace, with no access to yours.
Seats
Your plan sets how many people the workspace can hold. Members and invitations
that can still be accepted both occupy a seat, so an invite past the limit is
refused with 402 Payment Required naming the limit and how much of it is used.
Cancel a pending invite to get its seat back.
An expired invitation holds no seat. It can never be accepted, so it can never become a member — it is not in the Billing page’s usage, not in the 402 count, and not in any seat number the dashboard or a box shows. It stays listed on the Team page under Expired so it can be resent or removed; being visible and being counted are different things. Resending one revives it into a live invitation, which does take a seat back, so a resend past the limit is refused with the same 402 — remove a member or cancel a pending invite first.
Invitations that expired more than 30 days ago are deleted outright. The
sweep runs on a timer inside the proxy (daily, and once at startup), not when
somebody opens the Team page — so a workspace nobody has visited in months is
cleaned up too. Each sweep writes tap:invite-expired-purge to the affected
workspace’s audit log with the number removed.
Workspaces included in a contract
Some workspaces arrive as part of another agreement rather than as a subscription — a work box, for example, includes TAP. Those are on the bundled plan: agents, credentials and monthly requests are uncapped, and the only counted axis is people, whose limit is the seat count written into the contract rather than a number from the pricing page.
On a bundled workspace the Billing page reads Included in your contract and shows no plans, no checkout and no subscription controls — there is nothing to buy, and nothing to cancel. The usage table still shows seats used against the contract’s seat count, and inviting past it returns the same 402. To change the seat count, talk to your account contact; Stripe events never re-tier or re-seat a bundled workspace, so an old subscription being cancelled cannot quietly drop it back to the free plan.
Cancelling a pending invite
Pending invites appear below the member list. Click Cancel to revoke the link before it is accepted. After acceptance, use Remove instead.
Invitations that have expired are listed separately, under Expired, with Resend and Remove. They hold no seat, and either button clears them from the list — Resend by issuing a fresh link, Remove by deleting the row.
Operator provisioning
A workspace that arrives with a contract — a work box, say — should not require its customer to visit TAP first. One operator call creates the workspace, puts it on the contract’s plan and seats, writes an owner invite carrying the box’s brand and its own join page, and mails it. The first thing the customer ever sees is their own box’s join screen; the box-hosted wizard does the rest.
curl -X POST https://proxy.example.com/operator/teams \
-H "X-TAP-Operator-Secret: $TAP_OPERATOR_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"team_name": "acme",
"owner_email": "admin@acme.com",
"tier": "bundled",
"member_cap": 25,
"brand": { "name": "Acme Box", "logo_url": "https://acme.work.human.tech/mark.svg" },
"join_url": "https://acme.work.human.tech/join",
"redirect_uri": "https://acme.work.human.tech/auth/tap/callback",
"default_credentials": ["stripe"]
}'team_name— 3–64 characters, lowercase alphanumeric with hyphens, exactly the rule signup applies. Normalised to lowercase, so a provisioned workspace and a self-serve one of the same name are the same workspace.owner_email— the customer’s first admin. Invited asowner, so they can invite the rest of their team themselves.tier/member_cap— written together in the same transaction as the team, so the workspace is never briefly on the free plan.tierdefaults tobundled.member_capis the contract’s seats and must be at least 1 — the owner holds the first one, and a zero-seat contract is a workspace nobody can enter, refused here rather than discovered as a402later.brand,redirect_uri,join_url,default_credentials— the same fields, same validation and same meaning as on an ordinary invite.join_urlandredirect_urimust both passTAP_LOGIN_ORIGINSand be on the same origin as each other. One difference:default_credentialsare not checked against the team’s credentials, because a workspace created by this call does not have any yet — the box creates them after provisioning, and a grant naming a credential that never appears is skipped when the invite is accepted.
The response carries accept_url always, not only when mail delivery
failed: the caller is a provisioning script with no mailbox, and printing the
link is the documented fallback for an invite that never arrives.
Re-running provisioning
Provisioning scripts get re-run — a box redeploys, a step is retried. The call
is idempotent on (team_name, owner_email) and answers with an action
saying which of four things happened:
action | Status | What happened |
|---|---|---|
created | 201 | New workspace, new owner invite, mail sent |
invite_resent | 200 | The workspace and its invite already existed: reissued, new token, old token dead, mail sent again |
invite_recreated | 200 | The workspace existed with no live invite (it was cancelled): a fresh owner invite |
owner_already_accepted | 200 | The owner is already in. Nothing was sent; invite_id and accept_url are null |
Every re-run also re-applies tier and member_cap, so fixing a seat count and
re-running is the intended way to change one. A name that already belongs to a
different owner is 409 team_exists_other_owner — adopting it would hand a
stranger a customer’s seats, credentials and audit trail.
To retry only the delivery — a mail that bounced, a link found after it expired — use the resend route, which needs no session because there is often nobody in the workspace to hold one:
curl -X POST https://proxy.example.com/operator/teams/$TEAM_ID/invites/$INVITE_ID/resend \
-H "X-TAP-Operator-Secret: $TAP_OPERATOR_SECRET"The operator secret never reaches a box
TAP_OPERATOR_SECRET is platform-wide: it can create a workspace and set the
plan of any team. It belongs to the provisioning job that runs before a
box exists and nowhere else — never in a box’s environment, image, or config,
and never handed to a customer. A box authenticates as itself (its registered
origin and its team’s own keys); it has no need to provision, and nothing it
runs should be able to. Both operator routes answer 404 on a deployment where
the variable is unset, so a box that somehow tried would find nothing there.
Every call writes an audit row with no user session behind it —
tap:operator-team-create for a provisioning call, tap:operator-invite-resend
for a resend, both under agent id operator, recording the workspace, the owner
address, the tier and the seats.
API
For programmatic team management:
| Method | Path | Description |
|---|---|---|
GET | /team/members | List members and unclaimed invites (each carries expired, credentials, brand, redirect_uri) |
POST | /team/members/invite | Send an invite — see the body below. Returns {invite_id, email, role, expires_at, redirect_uri, join_url, brand, credentials}, plus accept_url when email delivery failed. 403 personal_workspace from a personal workspace |
POST | /team/members/invite/{id}/resend | Reissue an invite: new token, new 7-day expiry, new email. The old token dies in the same write. Owner/admin. 409 invite_already_accepted if it has been used |
POST | /operator/teams | Operator provisioning: create a workspace, set its plan and seats, and mail its owner a branded invite. X-TAP-Operator-Secret, never a session. Returns {team_id, team_name, owner_email, role, tier, member_cap, invite_id, accept_url, expires_at, action, email_delivered}. 400 reserved_team_name for a name starting personal- |
POST | /operator/teams/{team_id}/invites/{invite_id}/resend | Reissue a provisioned invite without a session. X-TAP-Operator-Secret. Returns {team_id, invite_id, email, role, accept_url, expires_at, email_delivered}. 404 invite_not_found, 409 invite_already_accepted |
GET | /invite/info?token= | Look up invite metadata (email, team name, invite_action, brand, redirect_uri, join_url, credentials, expires_at) without consuming the token. No auth required |
POST | /invite/start | Join wizard, identity step for an invitee with no account ({"token": "...", "password": "..."}). Creates the account, joins the team, assigns the invite’s credentials, emails a code. Returns {"next": "verify_email"}. 409 account_exists (with "next": "login") when the address already has an account |
POST | /invite/complete | Join wizard, final step. Session-authenticated or wizard_token-authenticated (see box-hosted join). Optional scopes on the box path. Makes the invited team active and returns {redirect_uri, team_id, team_name, member_role, credentials, teams} |
POST | /invite/login | Box-hosted join, existing-account branch ({"token": "...", "email": "...", "password": "..."}). Authenticates the invited address only, and mints a wizard_token — never a TAP session |
POST | /invite/verify-email | Box-hosted join, email step ({"token": "...", "code": "..."} + Authorization: Bearer <wizard_token>). Returns {"verified": true, "next": …} and no account-scoped token |
POST | /invite/passkey/begin | /finish | Box-hosted join, passkey registration through the related-origin verifier. Requires a registered Origin |
POST | /invite/passkey/assert/begin | /finish | Box-hosted join, passkey assertion for an invitee who already has one |
POST | /login/box/begin | Box-hosted returning sign-in, challenge step ({"redirect_uri": "...", "client_id": "...", "email": "..."}). Registered Origin required. Returns {challenge, login_token, login_token_expires_at, flow} |
POST | /login/box/finish | Box-hosted returning sign-in, assertion step ({"login_token": "...", "credential": {...}}). Returns {login_code, login_code_expires_at, scopes} and never a session |
POST | /team/members/accept | Legacy single-shot accept ({"token": "...", "password": "..."}). Creates brand-new accounts only — existing users log in instead, which consumes the pending invite |
POST | /session/pending-invites | Apply invites pending for the signed-in user’s own email, without re-logging-in. No body and no token: holding a session for the invited address is the authorization. Returns joined plus the refreshed teams list. Active team is left unchanged |
GET | /user/credentials | All my credentials. Session-authenticated and read-only. Returns {teams: [{team_id, team_name, display_name, kind, role, credentials: [{name, description}]}]} for every workspace you belong to: every credential where you are owner or admin, the ones you hold Use on where you are a Member. kind is company or personal. Never returns a value |
PUT | /team/members/{id}/role | Change a member’s role ({"role": "..."}). Owner-only |
DELETE | /team/members/{id} | Remove a member |
DELETE | /team/members/invites/{id} | Cancel a pending invite |
GET | /team/members/{id}/credentials | List a member’s assignments. credentials is the flat name list; grants carries can_use / can_approve / can_manage_policy per credential |
POST | /team/members/{id}/credentials | Assign a credential to a member ({"credential_name": "...", "can_use": true, "can_approve": true, "can_manage_policy": false}). The three bits are optional and default as shown |
PATCH | /team/members/{id}/credentials/{name} | Change some of an assignment’s grants. Omitted bits are left alone. 404 not_assigned if the member has no assignment on that credential |
DELETE | /team/members/{id}/credentials/{name} | Remove a member’s credential assignment entirely |
GET | /user/passkeys | Your own passkeys: credential_id, created_at, label, last_used_at |
PATCH | /user/passkeys/{credential_id} | Rename one of your passkeys ({"label": "..."}, 64 characters). Audited as tap:passkey-rename |
DELETE | /user/passkeys/{credential_id} | Remove one of your passkeys. 400 last_passkey when it is the only one left. Audited as tap:passkey-delete |
POST | /team/policy-changes/{txn_id}/passkey/begin | Start the passkey ceremony for a member-staged policy change |
POST | /team/policy-changes/{txn_id}/passkey/finish | Confirm and apply a member-staged policy change ({"assertion": {...}}) |
All endpoints except accept, /invite/info, /invite/start and /invite/login require a valid session token (Authorization: Bearer <token>). On the TAP-hosted wizard /invite/complete needs the session the passkey step just minted; on the box-hosted one it takes the wizard_token instead.
Team management from an app that signs you in with TAP
A subset of the routes above is authenticated by a dashboard session or a
Sign-in-with-TAP token with tap:full (Authorization: Bearer <access token>
— the same token the app already holds from Sign in with
TAP), provided the token was minted
for a work box provisioned for this workspace. An agent’s MCP connection
mints the same kind of token, so without that condition an agent holding an
owner’s token could grant teammates credentials with no one deciding; its token
is refused here with 403 box_token_required. That subset is exactly the one a
Team panel needs:
GET /team/members · POST /team/members/invite ·
POST /team/members/invite/{id}/resend · DELETE /team/members/invites/{id} ·
GET /billing (alias of /billing/status) · GET /team/credentials ·
GET/POST /team/members/{id}/credentials ·
PATCH/PUT/DELETE /team/members/{id}/credentials/{name}
Both credentials resolve to the same person, in the workspace the token names,
with the membership re-read on every request — so the role rules on this page
apply unchanged, and a member-tier token cannot invite. Everything else here
(role changes, member removal, passkey resets, the policy-change ceremonies)
still requires a session, and the passkey steps still require a passkey; a token
presented there is a 401. A token without tap:full is refused with
token_scope_insufficient, and an expired or revoked one with invalid_token /
token_revoked.
Writes on these routes are audited (tap:member-invite,
tap:member-invite-resend, tap:member-invite-cancel,
tap:member-credential-assign) and every row records which credential was used,
as auth_source: "session" | "oidc_token".
Invite request body
{
"email": "person@example.com",
"role": "approver",
"redirect_uri": "https://acme.work.human.tech/welcome",
"join_url": "https://acme.work.human.tech/join",
"brand": { "name": "Work Box", "logo_url": "https://acme.work.human.tech/mark.svg" },
"credentials": [
"stripe",
{ "name": "github", "can_use": true, "can_approve": true, "can_manage_policy": false }
]
}Only email is required.
role—approver(the Member role, default),admin, orowner. Only owners may invite an owner.redirect_uri— absolutehttpsURL. Its origin must matchTAP_LOGIN_ORIGINS(defaulthttps://*.work.human.tech), otherwise400 redirect_uri_not_allowed. Re-checked at hand-off: a target that is no longer allowlisted falls back to the dashboard rather than sending anyone off the deployment.join_url— absolutehttpsURL, and where the emailed link points. Absent ⇒{TAP_BASE_URL}/dashboard?invite_token=<token>, which is what every invite did before. Set it to the box’s own join page and the mail leads the invitee straight into the box-hosted wizard instead of out to TAP. It must pass the same allowlist asredirect_uriand be on the same origin as it — otherwise400 join_url_not_allowed; ajoin_urlwith noredirect_uriis refused for the same reason. The query parameter isinvite_tokeneither way, so the token is unchanged and the TAP-hosted wizard stays reachable if someone lands there. A resend reuses the stored value.brand— a string ("Work Box") or an object. The name is plain text, capped at 64 characters, control characters stripped.logo_urlmust be absolutehttpswith no embedded credentials. Bad values are400 invalid_brand.credentials— up to 25 entries, each a credential name or an object withnamepluscan_use/can_approve/can_manage_policy(the three grants). A name the team does not have is400 unknown_credential. Omitted bits default to the usual bundle: use and approve on, policy management off — so a bare name grants exactly what assigning a credential by hand does. The bits are applied as given when the invite is accepted; the response echoes them back incredential_grants.
Redirect behaviour
redirect_uri is stored on the invite, never carried by the browser. /invite/complete reads it back from the database at the final step and returns it, so a wizard begun on one proxy instance ends on the right destination from any other. If the invite named no target, or the target is no longer registered, the wizard lands on {TAP_BASE_URL}/dashboard instead.
Box-hosted join
The wizard above lives on the TAP origin. A work-box customer’s colleague clicks
a branded email, leaves <slug>.work.human.tech, finishes on TAP, and is
redirected back. For a product that hides TAP entirely, the join should never
leave the box’s own page — so the same wizard can also be driven from the
inviting app’s origin, with no navigation away from it.
Three things make that possible, and nothing else changed.
The email leads into the box. Set join_url on the invite (above) and the
emailed link is {join_url}?invite_token=<token> instead of the TAP dashboard,
so the wizard below is what the invitee actually reaches. Without it the box
page is never opened from the mail at all.
CORS, on these paths only. GET /invite/info, POST /invite/start,
/invite/login, /invite/verify-email, /invite/passkey/* and
/invite/complete answer a cross-origin request. Nothing else on the proxy
does. The allowlist is TAP_LOGIN_ORIGINS — the same list that validates an
invite’s redirect_uri — plus every origin registered as a WebAuthn related
origin. Access-Control-Allow-Origin is echoed for an allowed origin and
absent for any other; Vary: Origin is always sent; preflights answer 204
with GET, POST, OPTIONS and authorization, content-type.
No cookies, ever. Access-Control-Allow-Credentials is never sent, and no
step reads a TAP cookie. POST /invite/start and POST /invite/login return a
short-lived wizard_token (30 minutes, DB-backed, bound to one invite),
presented on every later step as Authorization: Bearer <wizard_token>. It is
authority over that wizard run and nothing else: it is not a session, it cannot
read or change a team, and it is spent by a single atomic claim at
/invite/complete — a replayed completion is 409 wizard_token_consumed.
The passkey goes through the related-origin verifier, exactly as in-page
approvals do. The box’s origin must be registered (POST /internal/webauthn-origins); an unregistered one is refused with 403 origin_not_registered before a challenge is minted. /invite/complete refuses
outright (403 passkey_required) if no passkey ceremony ran, so a page cannot
finish a join by skipping the step.
The registration is what admits the box, and it survives restarts. The
registered set lives in TAP’s database, is loaded into the verifier when a proxy
boots, and is re-read at ceremony time whenever the copy is older than 30 seconds
or does not carry the origin being presented — so a box registered a moment ago
works immediately, on every instance, without a redeploy. Registration is
idempotent: re-registering an origin that is already registered changes
nothing and disturbs no ceremony in flight, which makes re-running it the safe
first repair if a box page is refused with origin_not_registered.
The login handoff
POST /invite/complete on this path takes an optional scopes array and
returns a one-time login_code (2 minutes, single-use, bound to the
invite’s redirect_uri and to the scopes consented to):
POST /invite/complete
Authorization: Bearer <wizard_token>
Origin: https://acme.work.human.tech
{ "token": "<invite token>", "scopes": ["tap:full", "tap:approve"] }200 OK
{
"redirect_uri": "https://acme.work.human.tech/welcome",
"team_id": "…", "team_name": "Acme", "member_role": "approver",
"credentials": ["stripe"], "teams": [ … ],
"login_code": "…", "login_code_expires_at": "…",
"scopes": ["tap:full", "tap:approve"]
}scopes is the consent the box collects on its final screen — the screen
that just ran the passkey ceremony. Omit it and the handoff grants
tap:identity alone: silence is never read as consent to act. Every entry must
be a scope this deployment issues (tap:identity, tap:full, tap:approve);
anything else is 400 scope_not_allowed, never silently dropped. Anything
beyond tap:identity additionally requires the invite to name an allow-listed
redirect_uri — an invite with no target has no registered app to consent on
behalf of, and gets 400 scope_not_allowed too. A refusal leaves the wizard run
intact, so it is a retry and not a restart. The granted set is recorded on the
login-code row and written to the audit log as tap:invite-consent (origin,
client, scopes), and the redemption mints an assertion with exactly those
scopes — the OAuth client cannot widen them by asking for more at /authorize.
The box spends the code on its normal “Sign in with TAP” without asking the person for anything a second time:
- Redirect the browser to the usual authorization endpoint with the code
attached:
GET {TAP_MCP_PUBLIC_URL}/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&tap_login_code=<login_code>. - TAP redeems the code internally and redirects straight back to the box’s
redirect_uriwith an authorizationcode— no consent screen, no second passkey prompt. - Exchange that code at
/tokenas usual (PKCE verifier included). The response carries theid_token.
The code’s redirect_uri must share an origin with the invite’s, so a code
leaked out of one app cannot be spent into another. The client presenting it
must itself be one “Sign in with TAP” allows, checked at redemption against the
signed client registration — client_not_allowed otherwise. The token that
comes back carries the consented scopes and nothing more. Any refusal — spent,
expired, wrong origin, wrong client — falls through to the normal consent screen
rather than failing the login.
A note on “the client’s registered scopes”: TAP’s OAuth clients are
self-describing signed blobs carrying redirect URIs and no scope list, so
there is no per-client scope registry to subset against. The client-side bound
is its redirect origin — checked at consent (the invite’s redirect_uri must
be allow-listed) and again at redemption (the presenting client’s registered
origins must be allow-listed).
Threat model
Hosting the wizard in the box’s page moves two things inside the customer’s trust boundary that the TAP-hosted wizard deliberately kept out of it:
- The first factor. The password (or the existing-account login) is typed into a form the box serves. A compromised box page can capture it.
- The passkey registration. The ceremony now runs against the box’s origin, so a compromised page chooses when a passkey is registered for the invitee’s TAP account and can drive that ceremony.
- The scope consent. The box’s final screen is where the person agrees to
tap:full/tap:approve, so a compromised page can present that agreement dishonestly, or not at all.
That is acceptable for a customer’s own registered box, and only there. The box is the app the invitation was for, the app the person is being handed to, and the app that already holds the credentials the invite assigns — a box able to phish an invitee’s TAP password is a box that could already act as that person through its own session. The consent is collected on that same registered origin by the same page that ran the passkey ceremony, and the invite it completes was issued by the team’s own admin naming that client — so the authority being consented to is authority the inviting app was already meant to have. It is not acceptable for an arbitrary origin, which is what the two checks bound:
TAP_LOGIN_ORIGINSdecides who may drive the wizard at all. It is operator configuration, not something an inviter or an API caller supplies, and it is the same list that decides where an invitee may be redirected.- The registered-origin check decides who may run a passkey ceremony. That
set only grows through an operator-team call to
POST /internal/webauthn-origins, whose hosts must match a configured suffix — DNS we control.
What stays outside the boundary, deliberately:
- A box origin still cannot host a TAP account login.
/login/passkeyuses the strict verifier, built from configuration alone and never widened by a registered box. An assertion produced on a box origin does not verify there. - No step returns an account-scoped token to the box. No session, no
passkey_setup_token. The run’s only output is alogin_codebound to the invite’s ownredirect_uriand to the scopes consented to. - Consent cannot be inferred or widened. Absent
scopesmeanstap:identity; the granted set is fixed on the login-code row before the code exists, and the/authorizerequest that redeems it cannot ask for more. An origin outside the allowlist reaches none of this: it cannot start a wizard run, so it has nothing to consent with. Allow-Credentialsis never sent, so an allow-listed page cannot make the browser attach a TAP cookie and act as the signed-in user.
A ceremony belongs to the origin that began it. The related-origin verifier
accepts an assertion from any registered origin, so one registered box could
once complete a ceremony begun for another. The origin that calls begin is now
recorded and required at finish — for the wizard’s passkey registration and
assertion, for approvals, and for the returning sign-in below. A finish from
anywhere else is 403 origin_mismatch. TAP-origin ceremonies are unaffected:
they begin and finish on the same page, so they present the same origin.
The check fails closed: if the recorded origin cannot be read, the finish is
refused with 503 challenge_unavailable and the ceremony is retried, rather
than proceeding unbound — a read that failed says nothing about whether a
binding exists, and treating it as “none” would make a database fault a way to
switch the check off. The one ceremony that legitimately has no recorded origin
is one begun by an instance running the code from before this shipped and
finished by one running it after; that is allowed for the rollout window and
logged, so the window is observable rather than assumed.
Returning sign-in on the box
The wizard above is the first join. Returning sign-in — the box session expired, a new device, someone who joined months ago — used to send the person to TAP’s login page, which undoes the whitelabel for everyone after day one. It can now run on the box’s own page too, on the same CORS layer and the same registered-origin rule.
POST /login/box/begin
Origin: https://acme.work.human.tech
{ "redirect_uri": "https://acme.work.human.tech/auth/callback",
"client_id": "…", "email": "person@acme.com" }200 OK
{ "challenge": { "publicKey": { … } },
"login_token": "…", "login_token_expires_at": "…",
"flow": "discoverable" }Run the assertion in the page, then:
POST /login/box/finish
Origin: https://acme.work.human.tech
{ "login_token": "…", "credential": { … } }200 OK
{ "login_code": "…", "login_code_expires_at": "…", "scopes": ["tap:identity"] }The login_code is spent exactly as the join wizard’s is — attach it to the
normal authorization redirect as tap_login_code and the person lands signed in.
- Passkey only. No password is accepted here. The TAP-hosted login keeps password + passkey.
emailis a hint, never an answer. Supply it andallowCredentialsnames that account’s passkeys, so an authenticator holding several offers the right one. Omit it — the default — and the challenge names nobody: the credential the authenticator returns is what identifies the account. An address with no account, or an account with no passkey, is answered with the discoverable challenge, so this endpoint cannot be used to ask who has a TAP account.- The challenge is single-use (5 minutes, DB-backed) and records the origin,
the client and the purpose.
beginon one instance,finishon another. - The
redirect_urimust be allow-listed and on the calling origin. A page may start a sign-in into itself and into nothing else —403 redirect_uri_foreign_originotherwise. - Rate-limited per origin at
begin, and per origin + credential atfinish.429 rate_limited. - The account must be on the box’s team —
403 not_a_member, before anylogin_codeis minted — and the code carries that team. See below. - Every success writes an audit row:
tap:box-login, carrying the origin, the client, the flow and the granted scopes. Every refusal writes one too:tap:wizard-refusal, whoserequest_idis the one in the refusal body.
Refusing an account that is not on this box’s team
403 Forbidden
{ "code": "not_a_member",
"email": "nanaknihal@gmail.com",
"team_name": "Humanology and Health Sciences",
"error": "That passkey belongs to nanaknihal@gmail.com, which isn't a member of Humanology and Health Sciences. Use the address your admin invited, or ask them to add this one.",
"request_id": "9f1c…" }Show error verbatim and print request_id under it. The account’s address is
in email because the point of the refusal is that the person picked the wrong
one from a picker that offered several.
Which team is the box’s? The team whose invites name that origin. TAP’s
OAuth clients are dynamically registered, self-describing signed blobs with no
client table to hang a team_id on, and webauthn_origins is operator
provisioning with no team either. What does tie an origin to a team is an
invite: it carries team_id and the redirect_uri the invited person is sent
to, only an owner or admin can send one, and it is the act that creates the
box’s first member. So TAP reduces the client’s redirect_uri to an origin and
collects the distinct teams whose invites (redirect_uri or join_url) aim
there.
- Exactly one team ⇒ that is the box’s team, and a non-member is refused.
- Zero, or more than one ⇒ no team is derived and nothing is refused. A shared origin genuinely belongs to several teams; a refusal from a guess would lock out people who are entitled to that box.
This is a message, not a boundary. The authorization is still the box’s own
team check on the OIDC callback, and the login_code still carries a team. What
the check adds is the refusal arriving early enough to name the account and the
team. Every failure inside it — a database error, a URL that will not parse —
derives no team and lets the request through.
POST /invite/complete carries the same guard, for an invitation whose
redirect_uri names a box that resolves to a different team.
Which team the login_code carries
The box’s team, when one is derivable. A person on several teams used to get
a code for their oldest membership whatever box they were standing on, so the
box rejected an id token whose tap_team_id was not its own — the same
anonymous refusal, on the right account. The code now carries the team the box
belongs to, whose membership was just proven by the check above.
invite_login_codes.team_id is handed verbatim to the authorization assertion
at redemption, and tap-mcp puts it in the id token as tap_team_id.
When no team is derivable the choice is what it always was, and it is deterministic rather than incidental: the oldest membership, ties broken by team name — the same team the TAP-hosted login lands on.
POST /invite/complete needed no change here: its code has always carried the
invited team, which is the specific team the person was asked to join.
request_id on every refusal
Every structured refusal from /invite/* and /login/box/* carries a
request_id, logged and written to an audit row of that id. The journey crosses
four error vocabularies — the box page, these CORS routes, tap-mcp’s OIDC, the
box’s own callback — and this is the string that correlates them. Boxes should
print it under whatever sentence they show.
Which scopes a returning sign-in grants
tap:identity, unless this person has already consented to more for this
client — in which case the code carries exactly what they consented to, and
the sign-in is silent.
That record is client_consents, keyed by (user, client redirect origin). A
row is written when a login_code carrying more than tap:identity is
redeemed — the moment a consent given on the box’s join screen is actually
spent, not the moment a page claimed to collect it. Nothing else could answer
the question: invite_login_codes.scopes is per-code and gone on claim, the
audit log is history rather than current state, and TAP’s OAuth clients are
self-describing signed blobs with no scope registry to subset against. No row
means identity, always.
Threat model — returning sign-in
A passkey cannot be spoofed or replayed. The private key never leaves the authenticator, each assertion signs a challenge TAP minted for one use, and the browser binds it to an origin the authenticator itself checks. A hostile page can neither fabricate one nor re-use one it saw.
What a hostile page could do in general is (1) misdirect — get one real human touch and spend it on something the person did not intend, most obviously a TAP account session the page then holds — and (2) capture the first factor, if a password were typed on it. Both are removed by construction:
- Purpose-bound challenge. The row minted at
beginsays, before the person touches anything, exactly what the assertion may buy: a single-uselogin_codefor one recordedredirect_urion one recorded origin.finishhas no branch that returns a session or apasskey_setup_token, so there is nothing to misdirect to. - Passkey only. There is no password on this path, so there is no first factor on the page to capture.
- Origin binding. The ceremony is finishable only where it began, so one registered box cannot spend another’s.
What a compromised box still holds: its own box token — the credential it already had, able to do what that box’s own sessions could already do. It gains no TAP account authority, no session, no passkey it can move elsewhere, and no ability to widen scopes: a returning sign-in mints only what was already consented to for that same client, and consent beyond identity is recorded only when a person’s earlier consent was actually redeemed.