Team and roles
Who can sign in, what viewers, approvers and operators may do, setting up the first operator, and API tokens.
Each person signs in with their own email and password, and has one role. API tokens carry a role too. The roles are ordered, so each one can do everything the roles before it can:
| Role | Can |
|---|---|
viewer |
See orders, flows, approvals and the back office; use the ops chat (its proposals still need a person with the right role to confirm) |
approver |
Also approve and reject approvals |
operator |
Also start, cancel and restart orders, deliver events, create, edit and publish flows, run the stock review, manage webhook subscriptions, and manage the team and API tokens |
The roles and the order are ROLES in packages/core/src/roles.ts. Every route checks the role on
the server; the dashboard also hides controls a role can’t use.
Setting up sign-in
Section titled “Setting up sign-in”A deployment with no APP_PASSWORD and no people is open: everyone acts as an operator, which is
the local development default. To turn sign-in on, open Set up sign-in in the menu (the Team
page) and create your operator account with a password you choose. You are signed in as it, and
from then on everyone signs in.
With APP_PASSWORD set, sign-in is on from the start. Sign in with no email and the workspace
password to reach the Team page as an operator. The password stays a break-glass operator sign-in
and API credential; it has no identity, so its decisions are recorded as operator.
The Team page
Section titled “The Team page”Operators manage people and tokens at /team:
- Add person: email, optional name and role. The page shows a generated 20-character password once; hand it over and ask them to change it on their Account page.
- Role: change and save. You can’t lower your own role.
- Reset password: shows a new one-time password and signs the person out everywhere.
- Disable / Enable: a disabled person can’t sign in, and their sessions end.
- Without
APP_PASSWORD, the last active operator can’t be demoted or disabled, so someone can always manage the team.
Passwords must be 10 to 256 characters, not a common password and not contain the email address.
They are stored as PBKDF2-SHA256 (100,000 iterations, the Workers limit) as
pbkdf2:<iterations>:<salt>:<hash>.
Sessions last 30 days in a __Host-ol_session cookie that holds a random token; D1 stores only
its SHA-256. Signing out deletes the session.
Failed sign-ins
Section titled “Failed sign-ins”Failed sign-ins are counted in 15-minute windows, and an attempt over a limit is refused with
429 and a wait before any password is checked, even when it is right:
| Counted per | Failures allowed | Stops |
|---|---|---|
| Email and address together | 5 | Guessing one person’s password from one place |
| Address | 30 | One place trying many accounts |
| 50 | Many places trying one account; high, so nobody can easily lock a person out |
A successful sign-in clears its email-and-address count. The workspace password counts as one
email. Addresses come from cf-connecting-ip, which Cloudflare sets. The limits are LIMITS in
apps/web/app/lib/throttle.ts, and the counts are D1 auth_failures.
API tokens
Section titled “API tokens”Systems that call the REST API use a token:
curl https://orderloop.io/api/v1/orders -H "Authorization: Bearer ol_live_…"Create one on the Team page with a name (what will use it), a role and how long it lasts: 30 days,
90 days (the default), 1 year or never. The token is shown once and stored only as a SHA-256; the
list shows its first 12 characters, who created it, when it was last used and when it expires,
highlighting tokens that expire within 14 days. Revoke it there. An expired token gets 401 with
This API token expired on <date>…; create a new one and switch the integration over before the
old one runs out. Tokens made before expiry existed never expire. A token’s role limits it like a person’s: an approver token can
decide approvals but not start orders. Approval decisions made with a token are recorded as
token:<name>.
Audit log
Section titled “Audit log”Operators see security events at Audit log (linked from the Team page, /audit), newest first,
filtered by kind, and through GET /api/v1/audit-events.
Each event records when, who acted, what they acted on, details and the address:
| Kind | Actions |
|---|---|
| Sign-in | auth.sign_in, auth.sign_in_failed (with the email tried and why: unknown email, wrong password, disabled, no password set, wrong workspace password), auth.sign_in_throttled, auth.sign_out, auth.password_changed |
| Team | team.first_operator, team.user_added, team.role_changed (from and to), team.password_reset, team.user_disabled, team.user_enabled |
| API tokens | token.created (role, prefix and expiresAt), token.revoked |
| API access | api.bearer_failed and api.bearer_throttled, for wrong guesses at the shared password; api.token_expired, at most hourly per token, when an expired token is used |
A person signing in only hears that the email and password didn’t match; the reason is for operators. Passwords and tokens are never recorded. Events are kept 180 days. Writing an event never blocks the action it records. Approval decisions are recorded on the approval itself (below), not here.
Who decided
Section titled “Who decided”An approval’s by is the person who decided: their email for a signed-in person, token:<name>
for an API token. On the order page’s live connection, the agent takes the name from the
connection, never from what the page sends; see
OrderAgent.
Changes reach pages that are already open. Each action on the order page’s live connection checks the person’s session again, so a lower role applies to the next action, and signing out, a password reset or being disabled ends it.
Cross-site requests
Section titled “Cross-site requests”Every *.orderloop.io host counts as the same site, so the SameSite=Lax cookie would still be
sent with a form post from a sibling subdomain. The web Worker therefore refuses a browser request
that changes something (any method but GET, HEAD and OPTIONS), or opens the order page’s
WebSocket, unless its Origin is the app’s own; without Origin it reads Sec-Fetch-Site, and
accepts only same-origin or none. The answer is 403 { "error": "Cross-site request refused" }.
Requests with a bearer token are exempt, since they carry no cookie, and so are clients that send
neither header. The check is crossOrigin in apps/web/app/lib/origin.ts.
Outside identity providers
Section titled “Outside identity providers”users.external_sub is reserved for linking an outside identity. Signing in through an OpenID
Connect provider would match a person by that subject and start the same kind of session, keeping
roles and tokens in OrderLoop.