# Sign-in and API tokens

## People: sign-in methods

People sign in with a passkey, Google or an email link or code. A passkey can use Touch ID, Face ID, Windows Hello, a security key or a
password manager. Email contains a sign-in link and six-digit code. Passkeys come first; the page offers only methods configured
on this server. There are no passwords. Any of these methods can get a person into their account; **approving a post
still requires the owner's fresh passkey assertion over the exact plan**. Signing in never approves, schedules or posts.

Account access can start through:

- **Sign-up or agent access.** Anyone can start from
  `https://app.flaghand.com/start` or an agent's `POST /api/v1/signup` ([sign-up and billing](https://app.flaghand.com/docs/billing)). New people create an account
  and workspace using any configured sign-in method. An authoritative provider or email proof sets the new account's proven
  primary email; a passkey-only signup can initially have an unproven email. Existing owners sign in, choose a workspace
  they own and explicitly Allow that link's agent with the four OAuth scopes shown. A provider/email proof does not
  grant the agent access by itself. Allow is bound to the person whose identity was verified, not merely to the email
  the agent typed. The signup link alone cannot add a passkey to an existing account.
- **Operator bootstrap.** The operator runs `node tools/operator.mjs bootstrap --email … [--workspace ws_…]` with their
  own Cloudflare access. It creates the workspace (or uses an existing one) and its owner, and prints a one-time
  enrollment link that lasts an hour, to the operator only.
- **Invite.** An owner calls `POST /workspaces/{workspace}/invites/options`, then `POST /workspaces/{workspace}/invites`
  with a person's email, a role (`owner` or `editor`) and their passkey's assertion over that challenge
  (`challenge_id`, `response`). The answer has a one-time link for the owner to pass on. A new person registers their
  first passkey. An existing account joins only by signing with its own existing passkey: the link cannot add a passkey
  or let the inviter sign in as that person. Someone with no remaining passkey recovers through their own inbox first.
  An existing member is refused (`conflict`); an invite never changes a current member's role.

- **Reviewer link.** For a directory's reviewers (Anthropic's Connectors Directory) only: the operator runs
  `node tools/operator.mjs reviewer --email … --days N`. It makes a new account that owns a new workspace filled with
  synthetic drafts (no connected accounts, approvals or scheduled posts), and one enrollment link that each reviewer opens
  on their own device to add their own passkey, for the days the operator chose. It never adds a passkey to an existing
  account or reaches an existing workspace. Everyone who uses the link shares that one owner account, so reviewers
  shouldn't connect their own social accounts there, and the account can't invite anyone. `--workspace ws_… --days N`
  replaces the link (`--seed` adds the drafts again) and `--workspace ws_… --revoke` ends the link and every reviewer's
  access: their sessions, passkeys, links for another device and pending OAuth codes, the workspace's tokens and
  webhooks, and approvals of posts still waiting. Every other link works once.

The link opens `https://app.flaghand.com/enroll#token=…`. The token is in the URL fragment, so it never reaches a server log. The page
calls `POST /auth/enroll/options` and `POST /auth/enroll`. Normally the new passkey signs the person in. For an
existing-account invitation, options includes `action: "join"`: use a WebAuthn assertion (`navigator.credentials.get`),
not a registration, with the same finish endpoint. Accepting consumes the invitation once and signs that account in.

Signing in later starts at `https://app.flaghand.com/sign-in`. Passkeys use `POST /auth/sign-in/options` then `POST /auth/sign-in`
with the browser's assertion; provider and email proofs establish the same session. The Secure, HttpOnly,
SameSite=Lax cookie lasts 30 days with sliding renewal. Requests that change something with a session cookie must
come from `https://app.flaghand.com` (an `Origin` check), so another site can't act for you. Paths in this guide are under
`https://app.flaghand.com/api/v1` unless a full URL is shown.

### Email: one link and one code

`POST /auth/email/request` with `{email}` asks for a single email containing a sign-in link **and** a six-digit code.
For a pending signup the page also sends `signup_token`. The request retains the signup ID and that link token's hash,
not the raw signup token. If the pending signup-link token changes, an earlier requested email proof can no longer
complete that signup even within its ten-minute lifetime; use the current signup link to request a fresh email when
the 60-second send window permits. Without a signup, inbox proof signs in to an existing account;
it does not create a new one or accept terms. The link carries its secret in the URL fragment, not the path or query.
It opens a page where the person chooses Continue; **opening it with GET never consumes it**, so email scanners cannot
use it up. Non-sign-in links also carry an advisory `purpose=delete|recover|link` in the fragment so the page can name
the action before Continue; the server uses its saved purpose, never that hint, to authorize it. A link can open in a
new browser. The code must be entered in the browser that requested it, retaining its
HttpOnly `__Host-email-signin` cookie.

Both credentials prove the same request: they expire together after **10 minutes** and work **once**. After **5 wrong
codes**, that request's code stops working; the email link still works. After **20 consecutive wrong codes for one
address**, codes for that address stop working across browsers and new requests (`email_code_link_required`). Use the
link in the email or freshly sign in another way to restore codes. A correct code below twenty resets consecutive
failures too. Waiting, requesting another email or using an already signed-in session does not reset the count.
Using the link cancels its code and vice versa. An admitted request answers HTTP 202
`{request_id, status: "sent"}`; the page verifies with `POST /auth/email/verify` using either `{request_id, code}` or
`{request_id, link_token}`. Before its Continue, the link's page shows the address the link proves (read with
`POST /auth/email/preview` and the same `{request_id, link_token}`, which never uses the link), so a forwarded link
can't sign someone into the sender's account unnoticed. Ordinary success establishes the session and clears the request cookie. Wrong/mismatched,
expired, used and locked proofs have distinct errors; ask for a fresh email when the current one is no longer usable.

At most **one sign-in email per address per 60 seconds** is sent. A repeat sends nothing and answers HTTP 202
`{status: "recent"}` without a new request ID: check your inbox. It preserves a usable current-browser request cookie
and never extends the deadline or the send window. Once a fresh request is admitted, it replaces that browser's old
request. Known and unknown addresses receive the same email and the same sent/recent answers. Only after valid inbox
proof can sign-in return `account_not_found` for an address with no account. Email requests and checks count in the
existing approximate **30 requests/minute/IP** signup bucket, not a separate sign-in quota.

The email shows a coarse browser/device label and approximate city/country (US city/state) from the request's
User-Agent and trusted Cloudflare metadata. Neither IP, raw User-Agent nor the descriptive browser/place is stored or
logged. Codes and link credentials are stored only as hashes. Used requests delete their email, context and hashes;
only the request ID, outcome and original expiry remain for the used answer. A code-locked request keeps its usable
link until expiry or replacement. The consecutive-failure ledger holds only an address hash and count until successful
proof or account deletion, so a resend or expiry cannot restart guessing. A new
admitted same-browser request deletes the old one. Expired requests, outcome markers and the dispatch claims of sign-in
emails older than their ten minutes are removed by the existing minute cleanup after posting and billing, within its
query budget; expiry is enforced even before that sweep.

Sign-in mail uses the separate public-mail provider, not customer-mail sending capacity. A durable claim is recorded
**before** dispatch. There is no blind retry or fallback if the provider's response is lost: `sent` is not a promise
that the email arrived. After 60 seconds the person may ask for a fresh email.

### Settings, the primary email and recovery

Settings › Account lists passkeys and linked provider and email identities. `GET /auth/sign-in-methods` exposes
those lists, `session_method` and **`primary_email: {email, verified, available}`**. `verified` says whether the account's
primary inbox is proven; `available` says whether this server's email sender is configured. Provider subjects and
credential hashes are never returned. These are person-session operations, not agent-token or agent MCP permissions.

The primary email is the account's permanent recovery address. It remains an email sign-in/recovery path even if no
email identity row is listed, and cannot be removed. Adding another email or provider identity does **not** replace
it. An alternative email can be linked and then used to sign in or recover the same account.

To add a method, the person authorizes it with one tap of an **existing passkey**: the page calls
`POST /auth/identities/link/options`, then `/auth/identities/link` with the assertion. Its single-use `link_token`
authorizes verifying the new method for that same live session. The email request or provider flow uses
`purpose: "link"`; completion answers `{status: "linked"}`. An identity belonging to a different account cannot be
moved. Proving the primary inbox through this passkey-authorized flow does not reset existing access; proving an
alternative inbox does not prove a different primary email.

The passkey-derived link authorization lasts **5 minutes**, while an email link/code lasts **10 minutes**. Linking
needs both still valid. Sending another email cannot extend the authorization: if it expires, authorize linking again
and request a fresh email when the 60-second send window permits. The email proof can be consumed before the expired
link authorization is refused. A normal session cookie alone cannot add a method.

`DELETE /auth/identities/{id}` removes a linked identity and the sessions it established, but never the primary-email
identity. With email sending configured, primary-inbox sign-in/recovery counts as a remaining way in even if there
is no explicit primary-email identity row. Someone with only a provider identity may therefore remove that sole provider
identity and recover through their primary inbox. With email unconfigured, removing the last stored method is refused.
Removing the method that made the current session can sign this browser out.

`GET /auth/passkeys` lists passkeys and `DELETE /auth/passkeys/{id}` removes one, **never the last passkey**, even if
another kind of sign-in or primary-inbox recovery is available. A passkey approving waiting, scheduled or held
posts cannot be removed while those posts depend on it. To add a passkey on another device, use
`POST /auth/enrollments/options` then `/auth/enrollments` with an existing passkey assertion and open its one-time link
on that device. A session cookie by itself cannot add another key while the account has one.

“Lost your passkey?” offers configured linked provider methods and the primary email link/code. The recovery flow
uses `purpose: "recover"`: a fresh verified identity returns `status: "recovery"` and a one-time `enrollment_url`
lasting an hour. Open it on this device and add a passkey using the existing enrollment step; recovery is not just
an ordinary signed-in session. A linked alternative inbox is a recovery path too, without changing or proving the
primary address. The operator can also confirm ownership and run
`node tools/operator.mjs bootstrap --recover --workspace ws_… --email …` to issue an enrollment link.

Compatibility `POST /auth/recover` with the primary email still sends the existing one-hour enrollment link under its
separate five-minute email limit below. An operator/invite/device enrollment link does not itself prove an inbox.

### First approval without a passkey

A person who joined through a provider or email need not have a passkey yet. At their first approval, the approval sheet
first creates their first passkey and then asks for the usual assertion over the exact plan: **one sheet, two taps**.
The session-only `/auth/first-passkey/options` and `/auth/first-passkey` routes work only when the account has no
passkey; registration also requires a proven primary email and the originating live session. Creating the passkey is
not approval. The owner still performs the separate approval tap. A token, agent, provider login or email proof cannot
approve, schedule, post or start a waiting job.

### When the email is proven

A passkey-only signup can record an address before anyone has proved they read its inbox; someone else could have
typed it. A new account created through an authoritative provider email or a used email link/code starts with that
primary email proven. A non-authoritative Google signup starts unproven: its first inbox
proof runs the reset below and removes that earlier Google identity and its sessions. An existing unproven primary
address is proved when:

- a recovery link emailed to it is used;
- its sign-in email link or code is used, or a provider verifies that same primary email;
- the person signs up through the waitlist invite sent to that address (`https://app.flaghand.com/start#invite=…`).

An address belongs to one account: its primary email or an address one of its sign-in methods vouched for. A known
provider+subject selects its account. Otherwise, a verified email matching such an address signs in and links that
identity; unverified provider email never matches or links, and no second account is created with or linked to an
address another account holds. Google vouches for an address's current owner only for `@gmail.com`/`@googlemail.com`
or a Google Workspace account: a different Google account whose address matches an existing account gets
`email_code_required` (sign in with a code emailed to that address, then add Google in Settings). Such a Google
account can still create a new account or be added in Settings, but it proves no inbox and its address never matches
another sign-in. In ordinary sign-in or recovery, a first
proof of an unproven primary address applies the reset below, just like the existing recovery link. The page explains
that earlier access will end. This is not a refusal to sign in. If the person is already signed in and authorizes
Settings linking with an existing passkey, proving that primary inbox instead preserves earlier access: they showed
both the existing passkey and inbox. Linking a different address leaves the primary email and its proof status alone.

Links from the operator, invites and links for another device don't prove an inbox: they were not emailed to it.

At the first sign-in/recovery proof, everything the account had from before ends, except the newly proven sign-in
identity or the recovery passkey just added:

- its other linked sign-in identities and passkeys, and every old session (other devices are signed out);
- unused links that add a passkey, its own and the invites it made, unused OAuth sign-in codes and its sign-ups'
  claim codes, collected or not;
- every API token and app (OAuth) token it made, and all webhook endpoints it registered through a session or token
  (their pending deliveries are canceled and signing secrets erased). A token from before may be the other person's
  agent: it could read the workspace, draft and, with `billing`, cancel the plan, and nothing tells an owner's token
  from theirs;
- every workspace membership it granted, whether owner or editor, and the memberships those invited owners granted
  in that same workspace. Their pending invitations, workspace API/app tokens, OAuth codes, webhook subscriptions and
  approvals end too. This follows the grant's source, not the teammate's email-proof status. Their personal accounts,
  passkeys, sessions and independently granted access to other workspaces stay;
- the approvals of waiting, scheduled or held posts approved with a removed passkey (`void_reason` "its passkey was
  removed when the account's email was proven"), so those posts don't go out. Approve them again.

Each of the account's workspaces logs `member.email_proven` with what ended. Every withdrawn membership, pending
invitation and token logs `member.removed`, `member.invite_revoked` or `token.revoked` in its own workspace, with
`reason` `email_proven`, in the cleanup's transaction. Make new tokens in Settings › Agents and add your other devices
again. Re-invite the teammates you want; they rejoin with their own passkeys, and old tokens and approvals stay revoked.
An ordinary owner's first recovery also removes their pre-proof teammates: no check can distinguish them from a
squatter's other accounts. There's no new sign-up prompt or verification step.

For an identity proof, the newly proven identity, first-proof reset and new session commit together. For recovery-link
enrollment, the new passkey and first proof commit together, and session creation checks that the key still exists.
Enrollment, sign-in, agent-token creation and OAuth exchanges already in flight cannot restore access afterwards:
each credential write checks that its link, passkey, identity, session or claim still works in that same database operation.
Sign-up claims are explicitly revoked, including one being exchanged, even when sign-up and proof finish in the same
millisecond. The additive migration also enforces the workspace-authority checks for an older Worker still finishing
requests during deployment, and completes delegated withdrawal if that older code records the first proof.

Later proofs do not repeat the reset. A recovery flow can add a passkey; an ordinary identity sign-in can link its
verified method without removing earlier access. Accounts made before public sign-up opened count as proven from when
they were made.

### Recovery email limit

`POST /auth/recover` sends at most one recovery email per account in five minutes. The account's unique email (case
insensitive) keys the window; unknown addresses get the same window, even if they sign up before it ends. Every valid
request answers `200 {"ok":true}`, whether the account exists or the email is suppressed; no `Retry-After` or account
hint is returned. A repeated request sends nothing, creates no enrollment link and doesn't extend the window. The link
already in the inbox keeps working. Delivery failures keep the window too, since an unclear delivery may have succeeded.
This doesn't limit receipts, sign-up, sign-in, API use, other accounts or operator recovery links. The `ok` response
acknowledges the request, not email delivery.

The response registers background delivery with the Worker's `waitUntil` before doing any I/O: a slow email service
can't reveal whether the account exists, and disconnecting the client doesn't cancel a claimed delivery. D1 reserves
the individual target's window atomically across requests and Worker instances. A definite database failure before
sending releases that attempt's reservation for a retry; an unclear delivery keeps it.

`recovery_email_windows` keeps only the normalized target email's SHA-256 and the window's start time, never a raw email
or enrollment token. The minute cron removes expired rows in indexed SQL pages after posting and billing, using its
existing query budget, not a global purge during someone's recovery request. Account deletion removes that account's
window and its `recovery_email_dispatches` claims (keyed by the same hash) immediately.

Michael approved this limit on 2026-10-25 at 09:27 (decision 12 A): "i agree with choice a. go ahead." The approved
choice was one recovery email per account every few minutes to prevent inbox flooding and protect the shared Resend
quota used for legally required subscription acknowledgment emails (T-963's review; T-1048 implements five minutes).
This five-minute window adds no general account/API quota; email sign-in has the separate approved limits above.
Recovery now uses separate sending capacity from required billing and checkout
mail. Its durable dispatch claim prevents the same request being sent twice, without retrying uncertain delivery. A crash
before dispatch or an unclear provider answer can lose that request's email; ask again after five minutes for a fresh link.
This is at-most-once dispatch, not a promise that an email arrived. Billing keeps provider-native request deduplication.

Passkeys belong to the app's domain (the host of `https://app.flaghand.com`, or the parent domain the operator configures). Moving the
app to another domain means enrolling passkeys again there.

### Deleting an account

A normal signed-in session is never enough to delete an account. If the account has any passkey, deletion keeps the
existing passkey step-up: `POST /workspaces/{workspace}/delete/options`, then `/workspaces/{workspace}/delete` with
that fresh `{challenge_id, response}` assertion.

With **no passkey**, the signed-in owner confirms with an email link or code sent to the account's primary address,
bound to `purpose: "delete"`, that same live session and the workspace. Provider sign-in never confirms a deletion:
provider start refuses `purpose: "delete"` with `method_unavailable`. The email
request is `{purpose: "delete", workspace_id}`: the server chooses the session user's primary address, not an
arbitrary submitted email. Completing the email proof returns `{status: "verified", proof_token}`; it does not
delete anything or establish a normal new sign-in session. Send `{proof_token}` to `POST /workspaces/{workspace}/delete` to finish.

The deletion proof works once and lasts **5 minutes** from issuance. A proof for ordinary sign-in, recovery or linking
cannot substitute for it; a different session, workspace, ended owner membership or newly added passkey invalidates
it. Delete/link email proofs must finish with their originating session even when the link opens elsewhere. The code
also needs the browser's original email-request cookie. No GET deletes or uses the email proof.

Deleting a workspace removes its data and cancels its plan under the existing deletion policy. A person who belongs
to no other workspace is deleted with every sign-in identity and session; their primary/linked-email requests,
address windows and dispatch claims are removed too. An owner export lists sign-in-method metadata, never provider
subjects or authentication secrets. A person who still belongs to another workspace keeps that independent access.

## Agents: API tokens

An owner creates a token in Settings › Agents (Create token) or with `POST /workspaces/{workspace}/tokens`
(`{ "name": "…", "scopes": [...] }`); the operator can mint one with `node tools/operator.mjs token`. The token's value is in that answer only. Flaghand keeps only its
SHA-256, so a lost token is revoked (`DELETE /workspaces/{workspace}/tokens/{id}`) and replaced.

Send it as `Authorization: Bearer tok_…`. A token belongs to one workspace and can't see any other.

| Scope | Allows |
|---|---|
| `read` | Reading drafts, media, connections, approvals, jobs, metrics, top posts, members, events, feeds, the voice note and weekly posting times; checking drafts; verifying a connection's login; reading a posted X job back; asking TikTok for an account's posting options or a job's status |
| `drafts` | Creating and changing drafts (a change to what would be posted voids its approval); recording a posted job's metrics; adding and removing feeds; setting weekly posting times, the workspace's time zone, the voice note and the post-needs-me email switch; setting, raising or clearing any automation's credit budget and running automations (this spends credits already in the balance, never money: buying credits takes the owner's passkey) |
| `media` | Uploading media, changing its default alt text and saving LinkedIn link cards |
| `request-approval` | Asking the owner to approve checked drafts. With `drafts`, posting in one call (`POST …/posts`: create, check and request approval) |
| `cancel` | Canceling a waiting, scheduled, held or failed job, a LinkedIn video or document still processing, or a posted X job's pending repost |
| `connections` | Starting to connect an account: the answer is the platform's sign-in link, for the account's owner to open and answer |
| `billing` | Getting Checkout and billing portal links, confirming a Checkout, canceling and resuming the plan |
| `webhooks` | Registering HTTPS event receivers, listing endpoints and delivery results, removing endpoints; its per-endpoint signing secret is shown only at registration. Revoking the registering token also removes its endpoints |
| `calendar` | Creating or rotating the workspace's private calendar subscription, checking whether one exists, and disabling it. Its read-only URL is shown once; revoking the token that created it disables it |

No scope approves, schedules, posts now, disconnects accounts, invites people or manages tokens. Those routes take only
an owner's session, and approving also takes a fresh passkey assertion. A token calling them gets
`owner_session_required`, whatever its scopes. Starting a connection finishes only when the account's owner signs in
on the platform's own consent screen, and the connection then still posts nothing without an owner's approval.

Editors have the same abilities as a token with every scope, in the workspaces they belong to, except billing, webhooks, the calendar feed, the voice note, the post-needs-me email switch and the workspace's name and time zone, which are the owners'. Existing tokens do not gain the `webhooks` or `calendar` scopes;
an owner can create a separate token with it. OAuth connector tokens keep their existing first-run scopes.

## Apps: sign in by OAuth

An app that adds `https://app.flaghand.com/mcp` as a connector (Claude, ChatGPT, Claude Code, any MCP client that does OAuth) gets
its token by signing in, not by pasting one. **Chat apps use this route for new and existing accounts; don't ask
whether the person has an account and never start API sign-up or the CLI.** Follow the [chat-app guide](https://app.flaghand.com/docs/chat.md).
The no-account link on the sign-in page creates an account free with a passkey, without a card, and
returns to the same consent. The app sends the person to `https://app.flaghand.com/oauth/authorize`; the owner uses a configured
person sign-in method, picks a workspace they own and clicks Allow, and the app trades the one-time code (with its
PKCE verifier) for a token. The token is an ordinary workspace token, named after the app: `read`, `drafts`, `media`
and `request-approval`, or fewer if the app asked for fewer, never more. It shows in `GET /workspaces/{workspace}/tokens`
with `oauth` (the app's `client_id` and the host its sign-in went back to) and in the desk's Settings › Agents; revoking
it there, or the app calling `POST https://app.flaghand.com/oauth/revoke`, ends it at once. The protocol details are in
[agents](https://app.flaghand.com/docs/agents#mcp-from-claude-chatgpt-and-other-apps-sign-in).
