API reference
Accounts API
The control-plane API behind the console: browser contract, sessions, login flow, roles and handles.
https://accounts.aggre.org is the control plane: sign-in, personal accounts and organizations, invitations, API-key management and usage views. It is what the console calls. You do not need it to read data (that is the data API, which takes API keys); you need it only if you build your own console or automate account management from a browser session.
| Data API | Accounts API | |
|---|---|---|
| Host | api.aggre.org | accounts.aggre.org |
| Credential | API key (Authorization: Bearer aggre_sk_...) | Session cookie |
| Browser access (CORS) | none | only https://app.aggre.org and https://admin.aggre.org |
| Accepts the other credential? | no | no: it never accepts an API key |
The route list with parameters is generated in the Accounts API reference; the OpenAPI file is /openapi/accounts.yaml. Platform-staff routes (/v1/admin/*) are not documented here.
Browser contract#
- Every call is
fetch(url, { credentials: "include" })from the browser. The cookies belong toaccounts.aggre.org, so a server on another host never sees them: logged-in data is fetched in the browser, not server-rendered. - Every non-GET request needs all three: the
Originthe browser sends (it must be exactly an allowed app origin), the headerX-Aggre-CSRF: 1(any non-empty value), andContent-Type: application/jsonwhen there is a body. Missing or wrong:403 csrf_requiredor403 origin_not_allowed. - Every authenticated request, GET included, must carry an allowed
Origin; one without is treated as unauthenticated (401). - CORS: exact match on the configured origins,
Access-Control-Allow-Credentials: true, allowed headersContent-Type, X-Aggre-CSRF, methodsGET, POST, PATCH, DELETE, OPTIONS. No wildcard. Responses carryVary: OriginandCache-Control: no-store. - Cookies are
HttpOnly; Secure; SameSite=Lax, host-only, with the__Host-prefix:__Host-aggre_appfor the app audience and__Host-aggre_adminfor the admin audience. They are never interchangeable. - Success bodies are
{ "data": ... }; a secret or token is a sibling key (secret,token) and appears once. Errors use the shared envelope (Errors). - Times are RFC 3339 UTC, ids are UUID strings, handles are lowercase.
Signing in#
Login is a full-page navigation, not fetch:
https://accounts.aggre.org/login/{provider}?audience=app&return_to=https%3A%2F%2Fapp.aggre.org%2Fprovider is one of the ids returned by GET /v1/config (for example google, github, orbbit). return_to must be an absolute URL on the audience's own origin; anything else is replaced by that origin's root. The browser comes back to return_to with the session cookie set, or to {origin}/login?error=<code> with one of:
error | Meaning |
|---|---|
access_denied | The person cancelled at the provider. |
invalid_state | The login state is missing, expired, reused, or the browser finishing the login is not the one that started it. |
provider_error | The provider rejected the code, returned an invalid token or was unreachable. |
account_disabled | The account is suspended. |
not_staff | The admin audience was requested by a user who is not platform staff. |
identity_in_use | Linking a provider identity that already belongs to another user. |
session_required | Linking without a valid session. |
internal | Anything else. |
OIDC providers use authorization code with PKCE, state and nonce, and verify the id token's signature and claims. GitHub uses OAuth 2 with PKCE.
Who you become#
An identity is (provider, subject), never the e-mail address. A known identity logs you in as the same user. For a new identity, if the provider reports an e-mail it vouches for (a verified address) and an existing user's verified primary e-mail equals it, the identity is linked to that user (auto-merge); otherwise a new user and personal account are created. An unverified or absent e-mail never merges anything, and a later change of e-mail at the provider never moves an identity. You can link another provider while signed in (link=1) and unlink one as long as another remains.
Sessions#
A session is an opaque random token in the cookie; only its hash is stored. App sessions live 30 days from login (not sliding) and die after 14 days without use; admin sessions live 8 hours. At most 20 live sessions per user and audience: a login beyond that revokes the oldest. POST /v1/auth/logout revokes the current session and POST /v1/auth/logout-all all of them. A suspended account cannot sign in and all its sessions stop working.
Roles#
On a personal account you are the owner. An organization has owner, admin and member, always with at least one owner. Details of who can do what are in Organizations. A caller with no relation to an account gets 404 not_found (existence is not leaked); one with a relation but too little role gets 403 forbidden.
Handles#
Users and organizations share one namespace of handles: 3 to 39 characters of a-z, 0-9 and -, not starting or ending with -, no --. Reserved words (admin, api, app, docs, login, settings, ...) are refused. The first rename of an account is free; after that, once per 30 days (409 conflict with the next date). GET /v1/handles/{handle} checks availability.
Rate limits#
Besides the per-IP limit on every route, the login routes share a tighter per-IP limit; over it the answer is 429 rate_limited with Retry-After.
Source: content/api/accounts.md