Content-Type: application/problem+json { "type": "/errors/invalid-credentials" error type (URI) "title": "Invalid credentials" type title "status": 401 mirrors the HTTP status "code": "INVALID_CREDENTIALS" machine-readable "detail": "Invalid credentials" human, i18n-ready "request_id": "req_a1b" tracing · = X-Request-Id header } 422 — + errors: [{ "field", "message" }] 429 — + retry_after: 30 sec · + Retry-After header
errors array, not in the framework's default form.
ValidationError in its own
format — we intercept it and wrap it in Problem Details.
application/problem+json per
RFC 9457 (type · title · status ·
detail) + the code extension (a short,
stable token the client matches on) and
request_id in the role of instance
(the identifier of the specific occurrence).
| id | BigInteger | PK | — |
| Text | UNIQUENOT NULL | login identifier · lower() |
|
| password_hash | Text | NULLCHECK | NULL for SSO · CHECK ↔ auth_provider |
| full_name | Text | NOT NULL | — |
| role | Text | NOT NULLCHECK | owner · admin · member |
| status | Text | NOT NULLDEFAULTCHECK |
DEFAULT 'active' · active · deactivated
|
| auth_provider | Text | NOT NULLDEFAULTCHECK |
DEFAULT 'local' · local · okta · azure_ad
|
| must_change_password | Boolean | NOT NULLDEFAULT |
true after a temporary password is issued · gate
on sign-in
|
| created_at | DateTime(tz) | DEFAULT | server_default=now() |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger |
| last_login_at | DateTime(tz) | NULL | denormalization → Last login |
| timezone | Text | NULL |
override · IANA tz · NULL = inherits org
|
| locale | Text | NULLCHECK |
override · ru · en · NULL = inherits org
|
| date_format | Text | NULLCHECK |
override · NULL = inherits org
|
| last_chat_model | Text | NULL |
personal sticky chat-model pick ·
seeds new conversations
· NULL = admin default
|
| mfa_enabled | Boolean | NOT NULLDEFAULT |
true after the 1st code is confirmed
v2
|
| mfa_secret | Text | NULL | AES-256-GCM v2 |
| mfa_recovery | JSONB | NULL | hashes of single-use codes (argon2id) v2 |
suspended, pending).
deactivated — a block by an admin, not
self-removal.
must_change_password is set to true
when a temporary password is issued and cleared on a successful change at the
gate. While the flag is true, the server rejects any
authenticated request except password change and sign-out
(403 PASSWORD_CHANGE_REQUIRED) — the application admits
neither the UI nor a direct API call. The login / refresh response carries the
flag so the client can send the user straight to the change screen.
timezone / locale /
date_format — nullable: NULL means
“inherit the organization's default”, set in the profile. They affect
only the frontend display; storage and the API are UTC. The full
precedence chain — in Admin Panel.
audit_log. The duplicate on
users is needed so the user list opens without a JOIN to the
journal.
UNIQUE (lower(email)) (or citext).
A case-sensitive UNIQUE would let
Alice@x and alice@x through as two accounts.
invite_tokens.email,
identity_mapping.source_email, and in the brute-force key
(brute:account:{email_hash}): otherwise a guessing attempt
resets the counter by changing case.
role,
status, auth_provider,
result) are plain Text with a
CHECK constraint, not a native PostgreSQL ENUM.
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | CASCADE |
| token_hash | Text | NOT NULLUNIQUE | cryptographically random (unlike family_id) |
| family_id | UUIDv7 | NOT NULLIDX | reuse detection → Token family |
| is_revoked | Boolean | NOT NULLDEFAULT | DEFAULT false |
| expires_at | DateTime(tz) | NOT NULL | sliding 30d |
| absolute_expires_at | DateTime(tz) | NOT NULL | ceiling 90d |
| remember_me | Boolean | NOT NULL DEFAULT false | choice at sign-in · rotation reissues a cookie of the same kind |
| user_agent | Text | NULL | device · captured at sign-in and refresh |
| ip | Text | NULL | address · real IP from nginx · geo at render time |
| created_at | DateTime(tz) | DEFAULT | now() · the session's “last activity” |
| id | BigInteger | PK | — |
| Text | NOT NULL | who is invited | |
| role | Text | NOT NULLCHECK | role to assign |
| token_hash | Text | NOT NULLUNIQUE | — |
| invited_by | BigInteger | FK→usersIDX | CASCADE |
| expires_at | DateTime(tz) | NOT NULL | 48h |
| accepted_at | DateTime(tz) | NULL | NULL = not yet accepted |
| created_at | DateTime(tz) | DEFAULT | now() |
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | CASCADE |
| token_hash | Text | NOT NULLUNIQUE | SHA-256 · only the hash in the DB |
| expires_at | DateTime(tz) | NOT NULL | 1h |
| used_at | DateTime(tz) | NULL | NULL = not yet used |
| created_at | DateTime(tz) | DEFAULT | now() |
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | CASCADE · the issuing account |
| code_hash | Text | NOT NULLUNIQUE | SHA-256 · only the hash in the DB |
| expires_at | DateTime(tz) | NOT NULL | 15m |
| used_at | DateTime(tz) | NULL | NULL = not yet used |
| created_at | DateTime(tz) | DEFAULT | now() |
used_at; sending another
email revokes the user's previous token. Expired and
used are indistinguishable to the client — a single response
410 RESET_EXPIRED. The flow machinery — in
authentication.
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | owner · CASCADE · key → user_id → role / ACL |
| key_hash | Text | NOT NULLUNIQUE | SHA-256 · only the hash in the DB |
| prefix | Text | NOT NULLIDX | ach_xxxx · identification in the list, not a secret |
| scope | JSONB | NOT NULL | access (read-only) + sources (null = all available) · never exceeds owner's permissions |
| expires_at | DateTime(tz) | NULL | 30 / 90 / 365 days · NULL = never-expiring |
| last_used_at | DateTime(tz) | NULL | the fact and time of use · “Used” in the list |
| is_revoked | Boolean | NOT NULLDEFAULT | DEFAULT false · revoked manually or by cascade on deactivation |
| revoked_at | DateTime(tz) | NULL | the moment of revocation · set once, never overwritten · “Revoked keys” in the profile |
| created_at | DateTime(tz) | DEFAULT | now() · the moment of issue |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · the key mutates in place (is_revoked, last_used_at) |
user_id is known at once),
consumed by returning it to the bot in a DM: it links the returning
source_user_id to the issuing account via
identity_mapping.
The code itself is channel-neutral — source (slack ·
telegram · mattermost) is set by the receiving bot;
UNIQUE(source, source_user_id) catches a repeat link
(409 ALREADY_LINKED). Expired and used are indistinguishable
to the client — a single 410 LINK_EXPIRED.
family_id — a UUIDv7, assigned at sign-in,
inherited through rotation. Reuse detection acts within a
chain, without touching all of the user's sessions.
family_id — it breaks off
one rotation chain, not all of the user's sessions. UUIDv7, not
v4: the timestamp in the prefix orders the keys, so inserts
land at the end of the index (like an auto-increment). Stored as the native
uuid type (16 bytes), not text. An internal grouping
key, not a secret — cryptographic randomness isn't needed (unlike
token_hash).
is_revoked, not expired); the same
criterion drives both the count in the
user card
and the list in
session management.
A session card in the UI is the family's last row: its
created_at gives “last activity”, and
user_agent and ip give the device and address.
user_agent and ip are written by the backend at
sign-in and on every refresh from the current request
(the User-Agent header and the real IP from nginx —
X-Forwarded-For), not on the client's word. There is no separate
“last activity” field: rotation already inserts a new
row, and its created_at is the last
activity. Geo is not stored — it is derived from ip at
render time (approximate; VPN / proxy distort it). The current
session is identified by matching the request's refresh cookie with
family_id.
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | CASCADE |
| source | Text | NOT NULLUNIQUE* | sign-in channel: SSO · slack · telegram · mattermost (part of the composite UNIQUE) |
| source_user_id | Text | NOT NULLUNIQUE* | id at the provider (part of the composite UNIQUE) |
| source_email | Text | NULL | auto-match — verified only |
| created_at | DateTime(tz) | DEFAULT | now() |
source here is the sign-in channel: an SSO provider (Okta, Azure AD)
or a messenger bot (source='slack'
→ Slack,
source='telegram'
→ Telegram,
source='mattermost'
→ Mattermost),
not a content source. Who authored content in Jira / Slack /
Confluence and the consolidation of people into an identity is
source_principal / identity in Knowledge
Store; the bridge is identity.user_id → users.
| id | BigInteger | PK | — |
| actor_id | BigInteger | NULLIDX |
NULL = system · no FK
(independent of the users lifecycle)
|
| action | Text | NOT NULL | — |
| target_type | Text | NULL | — |
| target_id | Text | NULL | — |
| result | Text | NOT NULLCHECK | success · failure |
| ip | Text | NULL | — |
| user_agent | Text | NULL | — |
| meta | JSONB | NULL | arbitrary context |
| created_at | DateTime(tz) | DEFAULTIDX | now() · indexed |
actor_id = NULL means
system; no FK on users — the audit is kept when
an account is deleted (independent of the users lifecycle).
result CHECK success | failure.