ligo
Ligo
Shared Crystal/Marten engine behind Systatum's apps. Depended on as a shard by each app (currently Antrikan and Workaty), which each get their own database and install both Ligo::App and their own app alongside it.
Ligo owns generic mechanism - authentication, cross-app identity federation, uploaded files, dynamic per-resource fields, websocket pub/sub, background jobs. It deliberately does not own app-specific policy (specific roles, specific resource meanings, specific business rules) - that stays in the consuming app.
- Setup
- Entities
- Route Structure
- OAuth / Realm Federation
- Webhook Delivery
- JWT Token
- Email Verification
- Dynamic Fields
- Conventions
Setup
docker compose up -d
shards install
crystal run manage.cr -- migrate
crystal spec
Ligo also runs as its own standalone server (src/server.cr), mainly useful for exercising it directly during development; in production each consuming app links against it as a shard instead.
Entities
| Concept | Description |
|---|---|
| User | A person with an account. Has a canonical iam_identifier_primary used to resolve the same person across realms. |
| Organization | A group a user belongs to. |
| Realm | An OAuth client registration - a trusted app (e.g. Antrikan) that delegates authentication to this deployment. Each realm brings its own upstream OAuth app credentials so the user sees the correct branding. |
| RealmUser | A federated identity link binding a User to a specific Realm (like an OAuth sub per client). |
| WebhookDelivery | Tracks delivery attempts for outbound registration notifications sent to a realm's backend. |
| UploadedFile | A file committed to storage, referenced by id from any resource. |
| FieldDefinition / FieldValue | A generic per-resource custom field system. resource_type is a plain int: 0-99 are reserved for Ligo's own resource types (see ResourceType), consuming apps constantize their own starting at 100. |
| Changelog | An audit trail of create/update/delete changes for any model that includes ChangeTracker. |
Route Structure
| Scope | Route | Method | Handler |
|---|---|---|---|
| User | /user/signup |
POST | SignUpHandler |
| User | /user/signin |
POST | SignInHandler |
| User | /user/update_password |
POST | PasswordUpdateHandler |
| User | /user/request_password_reset |
POST | PasswordResetInitiateHandler |
| User | /user/reset_password/confirm |
POST | PasswordResetConfirmHandler |
| User | /user/bio |
GET | BioShowHandler |
| User | /user/bio/update |
PATCH, POST | BioUpdateHandler |
| User | /user/my-picture |
POST, PATCH | MyPictureUploadHandler |
| User | /user/request-email-verification |
GET | RequestEmailVerificationHandler |
| User | /user/verify-email/confirm |
GET | EmailVerifyHandler |
| User | /user/verify-email/form |
GET | EmailVerificationFormHandler |
| Realm | /realm/create |
POST | RealmCreateHandler |
| Realm | /realm/<realm_id>/oauth/<provider> |
GET | OAuthInitiateHandler |
| Realm | /realm/<realm_id>/oauth/<provider>/callback |
GET | OAuthCallbackHandler |
| Webhook | /webhooks/iam |
POST | IamWebhookHandler |
| Generic | /request_state |
GET | RequestStateHandler |
| File | /uploaded_file/<id> |
GET | UploadedFileShowHandler |
| Field | /fields/create |
POST | FieldCreateHandler |
| Field | /fields/<field_hashed_id>/update |
POST | FieldUpdateHandler |
| Field | /fields/<field_hashed_id>/delete |
POST | FieldDeleteHandler |
| Field | /fields/rt/<resource_type>/index |
GET | FieldIndexHandler |
| Job | /job/<id> |
GET | JobStatusHandler |
There is deliberately no organization CRUD here - the old monorepo's organization endpoints had no authorization checks at all, so they were left out rather than ported insecure.
OAuth / Realm Federation
sequenceDiagram
autonumber
participant Frontend
participant Ligo
participant Provider as OAuth Provider (Facebook/Google/...)
participant TargetApp as Target App (e.g. Antrikan)
Note over Frontend,TargetApp: Authentication Phase
Frontend->>Ligo: GET /request_state
Ligo-->>Frontend: { "state": "<signed_token>" }
Frontend->>Ligo: GET /realm/<id>/oauth/<provider>
Ligo-->>Frontend: 302 Redirect to provider's consent dialog
Frontend->>Provider: User consents
Provider-->>Frontend: auth code + state
Frontend->>Ligo: GET /realm/<id>/oauth/<provider>/callback?code=...&state=...
Ligo->>Ligo: validate state token (HMAC)
Ligo->>Provider: exchange code for access token
Provider-->>Ligo: user profile
Ligo->>Ligo: find or create User + RealmUser link
Ligo-->>Frontend: { "jwt_token": "<JWT>", "id": "...", "email": "..." }
Note over Frontend,TargetApp: Resource Access Phase
Frontend->>TargetApp: request with Authorization: Bearer <JWT>
TargetApp->>TargetApp: decode JWT, resolve user by id + password_updated_at
Supported upstream providers: Facebook, Google, GitHub, GitLab, VK, Twitter (see ProviderType). Adding another means adding it to multi_auth's own provider list and to ProviderType.
State Token
CSRF protection for the OAuth callback uses HMAC-signed nonce tokens via SignedToken. A state parameter is generated by RequestStateHandler and verified in OAuthCallbackHandler; missing or empty state is accepted (some frontend flows never round-trip it), but an invalid one is rejected.
Realm Registration
When registering a new Realm via POST /realm/create:
- A 12-character id is generated for the Realm.
- Three credential pairs are created (raw values returned once, never stored again):
api_secret_key(60 chars, backend-to-backend), stored as a SHA-256 hash;api_client_key(60 chars, frontend-to-Ligo), also hashed;webhook_secret(15 chars, signs outbound webhook payloads viaX-Systatum-Signature). - The realm's upstream OAuth app credentials (
app_id,app_secret) are stored for the OAuth flow. - An
ack_semanticis chosen (see below).
Webhook Delivery
The ack_semantic on a Realm controls how its backend is notified when a new User registers through that Realm's OAuth flow:
| Semantic | Behavior |
|---|---|
| NONE | No webhook. RealmUser#acked_at is set immediately at creation. |
| OPTIONAL | A webhook POST is sent to registration_ack_uri once, no retries. acked_at is never set automatically. |
| MUTUAL | A webhook POST is sent with retries (up to max_ack_retry_attempts, default 50) until the target responds 200/204. acked_at is set only on confirmation. |
sequenceDiagram
autonumber
participant Ligo
participant Redis as Sidekiq (Redis)
participant TargetBackend as Target App Backend
Ligo->>Ligo: User registered via OAuth (new RealmUser)
Ligo->>Ligo: generate content_id, set registration_webhook_content_id
alt ack_semantic == NONE
Ligo->>Ligo: set RealmUser.acked_at = Time.utc
else ack_semantic == OPTIONAL or MUTUAL
Ligo->>Redis: enqueue WebhookDeliveryWorker(content_id)
end
Redis->>WebhookDeliveryWorker: perform(content_id)
WebhookDeliveryWorker->>WebhookDeliveryWorker: sign body+timestamp with webhook_secret
WebhookDeliveryWorker->>TargetBackend: POST registration_ack_uri
Note over WebhookDeliveryWorker,TargetBackend: Headers: X-Systatum-Timestamp, X-Systatum-Signature
alt response 200/204
TargetBackend-->>WebhookDeliveryWorker: OK
WebhookDeliveryWorker->>Ligo: WebhookDelivery(succeeded: true), acked_at = Time.utc
else timeout / non-2xx
TargetBackend-->>WebhookDeliveryWorker: FAIL
WebhookDeliveryWorker->>Ligo: WebhookDelivery(succeeded: false)
opt ack_semantic == MUTUAL and attempts remain
WebhookDeliveryWorker->>Redis: retry with backoff
end
end
Webhook Payload
{
"event": "registration",
"user_id": "<hashed_user_id>",
"email": "user@example.com",
"realm_user_id": 42
}
Verifying an Incoming Webhook
A receiving app should verify every webhook itself:
- Extract
X-Systatum-TimestampandX-Systatum-Signature. - Compute
HMAC_SHA256(body + timestamp, webhook_secret). - Compare to
X-Systatum-Signatureusing constant-time comparison. - Discard (respond
400) on mismatch.
IamWebhookHandler (POST /webhooks/iam) is Ligo's own out-of-the-box receiver for this same protocol, for a deployment acting as the target of another Ligo deployment's webhook. It validates body presence, header presence, the configured ligo_webhook_secret, and the signature, then calls find_or_create_by_iam_identifier to resolve the user locally.
JWT Token
| Claim | Type | Description |
|---|---|---|
id |
int |
User's database id |
iat |
int |
Issued-at timestamp |
exp |
int |
Expiry timestamp (1 year) |
puat |
float |
password_updated_at. If the stored value no longer matches, the token is stale (password changed since) and lookup fails. |
ver |
int |
Token structure version |
given_name / family_name / email |
string? |
User profile fields |
role |
int |
User's role level |
find_by_jwt_token is deliberately strict: it looks up an existing user by id and rejects the token if puat doesn't match, rather than auto-provisioning. Cross-deployment identity resolution only happens through the explicitly HMAC-verified webhook path above, never through bare JWT trust.
iam_identifier_primary
A UUID assigned explicitly - during OAuth sign-in, or when a webhook creates a brand new user - never auto-assigned on every save. A user who has never federated anywhere has a nil value, which is what lets the very first webhook bind to them by email; see IamIdentifiable.
Email Verification
On sign up, a user's email is unverified (is_email_address_verified: false) and sign-in is blocked until it's verified.
| Route | Method | Purpose |
|---|---|---|
/user/request-email-verification |
GET | Sends a verification email. Query: email (required), redirectionUri (optional), expiredAt (optional ISO 8601, default never expires). |
/user/verify-email/confirm |
GET | Verifies by code (required) and optional user hashed id. Redirects to redirectionUri or shows a success page. |
/user/verify-email/form |
GET | Renders a small HTML form for manual code entry (user required, redirectionUri optional). An expired link auto-sends a fresh one. |
OAuth sign-ins are marked verified automatically, since the upstream provider already verified the email.
Dynamic Fields
FieldDefinition (schema) and FieldValue (data) let any app attach custom fields to its own resources without Ligo needing to know about them ahead of time. resource_type is a plain int rather than a fixed enum so apps can define their own:
module ResourceType
MY_THING = 100 # 0-99 are reserved for Ligo's own types
end
FieldCreateHandler/FieldUpdateHandler/FieldDeleteHandlermanage definitions directly, keyed by the rawresource_typeint.FieldIndexHandler(GET /fields/rt/<resource_type>/index) lists a resource type's fields, cached for an hour.- Reading/writing values for a specific record is done through the owning resource's own handlers, via
FieldValueConcernson that model - there's no separate FieldValue HTTP surface.
Conventions
- Prefer
T[]over[] of Tfor array literals - equivalent, reads cleaner. - Public identifier fields exposed over HTTP are always named
_id, never_slug, even for slug-only models. Raw database ids are never exposed either way. Indexhandlers return the current user's own data;Listhandlers are the admin/staff "for everyone" view. Don't conflate the two names.- Comments should be one line, only where the why isn't obvious from the code - not restating what the code already says.
- Ligo owns the generic mechanism; an app owns its own specific policy (its own roles/privileges, its own resource-type meanings, its own business rules). When in doubt about which side something belongs on, that's the question to ask.
ligo
- 0
- 0
- 0
- 0
- 16
- about 3 hours ago
- September 14, 2026
Mon, 14 Sep 2026 01:25:32 GMT