Architecture and schema overview
JaGate is one Node.js process with a versioned HTTP API, a core request lifecycle, a SQLite database, and a Telegram adapter. A caller proposes an action and later performs it itself. The gateway stores intent, routes it to that client's Telegram chat, records a human decision, and coordinates a single claim; it never executes the action.
flowchart LR
Caller[Calling applications<br/>HTTP or TypeScript client] -->|Per-client bearer key| API[Fastify /v1 API]
API --> Core[GatewayCore<br/>request lifecycle]
Core <--> DB[(SQLite)]
Worker[TelegramGateway<br/>delivery and polling] <--> Core
Worker <--> Transport[One bot transport]
Transport <--> Telegram[Telegram Bot API]
Telegram <--> Chats[Per-client chats and approvers]
Caller -->|After approved claim| Action[Caller-owned action]
Console[Operator console] -->|Same-origin bearer key| APIResponsibilities
| Component | Responsibility |
|---|---|
src/config.ts and src/main.ts | Validate environment configuration, open SQLite, start Telegram and HTTP, and shut them down. Only one process should use a bot token and database. |
src/http.ts | Resolves each /v1 bearer key to a client ID, checks issued-key scopes on every route, validates bodies and IDs, maps lifecycle errors to documented HTTP responses, exposes /health and /ready, and sets security headers on every response. |
src/core.ts | Owns state transitions, idempotency, expiry, delivery records, atomic claim, result reporting, and transactional event records. It has no HTTP or Telegram request types. Its clock is injected for deterministic tests. |
src/storage.ts and migrations/ | Opens SQLite in WAL mode and applies numbered SQL migrations transactionally. |
src/telegram.ts | Formats and escapes messages, routes them by client ID, checks each client's numeric chat and user IDs, polls callbacks, and asks the core to decide. The transport interface lets tests use an in-memory fake. |
src/client.ts | Makes authenticated HTTP calls and polls for a decision. Its wait timeout and AbortSignal affect only the client wait, not the stored request. |
console/ and src/console-host.ts | Vue operator console built with Element Plus. npm start, npm run start:local, and the Docker image serve dist/console on the same origin as /v1 when that build is present. npm run dev does not. The page holds one bearer key in memory. It can list, read, and optionally cancel. The bootstrap key can also issue keys and read audit events. It does not approve, claim, or report a result. |
The HTTP API is usable from any language. The TypeScript client is a convenience wrapper over that same API, not a separate approval path.
How a request moves through the system
- The caller sends
POST /v1/requestswith its own bearer key, an idempotency key, and the exact action description. The HTTP layer resolves the immutable client ID from the key. Validation bounds text, metadata, details, and expiry. The core stores the canonical content and its fingerprint before attempting Telegram delivery. A repeat under the same client ID with the same key and content returns the existing row; changed content returns a conflict. Another client can reuse that idempotency key independently. - The Telegram worker reads due rows from SQLite, resolves each row's client ID to its configured chat, sends a message, and records the chat and message IDs. The message shows the client ID, action, title, description, details, short request ID, and expiry. Metadata stays with the owning client's API response and is not sent to Telegram. Transient send errors update the row to
retryingwith bounded backoff; a terminal error is visible asfailed. - One bot long poller receives button callbacks from all chats. The adapter resolves the opaque callback reference to a client, then checks that client's numeric approver user ID and current destination chat ID. The core checks the reference against the stored chat and message IDs and conditionally updates a still-pending, unexpired request. Repeated, late, wrong-chat, or wrong-message callbacks cannot reverse a decision. The adapter answers the callback and tries to edit the message to remove its buttons.
- Only the owning client can read
GET /v1/requests/:id, list its requests withGET /v1/requests, or usewaitForDecision. Listing applies optional status filters and a bounded cursor page under the resolved client ID. On approval, it callsPOST /v1/requests/:id/claim. One owner-scoped conditional SQLite update moves execution fromunclaimedtoclaimed; other claim attempts get a conflict. The response contains a one-time claim token. Only its hash is stored. - The caller performs the action with its own credentials and reports
succeededorfailedusing the claim token. The gateway records that outcome but cannot verify or undo the external action.
SQLite schema at a glance
The schema starts in migrations/001_initial.sql. Later numbered migrations add destination chat binding, request listing, per-request history, issued keys, and migrations/006_key_expiry_audit.sql for key expiry and audit events. They apply automatically when the database opens. There are six tables and no user-account or workflow tables.
| Table | Main fields | Purpose |
|---|---|---|
requests | id, client_id, idempotency_key, fingerprint, content_json, action/title/description/details/metadata, timestamps | One immutable proposal owned by a client. (client_id, idempotency_key) is unique; the canonical content and fingerprint enforce idempotency across restarts. |
requests | decision_status, decided_by, decided_at, expires_at | Human decision or pending expiry/cancellation. decided_by is the numeric Telegram user ID for button decisions. |
requests | delivery_status, delivery_attempts, next_delivery_at, delivery_error, callback_ref, delivery_chat_id, delivery_message_id | A small durable notification queue and the binding between one Telegram button and its stored chat and message. The callback contains only the opaque reference and desired decision. |
requests | execution_status, claimed_at, claim_id, claim_token_hash, result_summary, result_at | Claim ownership and the caller-reported outcome. The raw claim token is never stored. |
request_events | request_id, sequence, type, occurred_at, actor_id, attempt | Append-only state transition timeline for one request. The event sequence is unique per request. Sensitive request content and authorization material are omitted. |
client_keys | id, client_id, label, key_hash, scopes_json, created_at, expires_at, revoked_at | Hashed, scoped, optionally expiring and revocable issued credentials owned by a configured client. The raw key is returned once and is not stored. |
audit_events | id, client_id, type, occurred_at, actor, actor_key_id, request_id, subject_key_id | Append-only client-scoped attribution for successful request creation, claim, result, and key issue or revocation. It stores IDs, never credentials or request content. |
settings | key, value | Stores the Telegram polling offset. An update ID is saved only after that update has been handled. |
schema_migrations | version | Records which numbered SQL migrations were applied. |
The requests row is the authoritative current state. Each successful state change appends its event in the same SQLite transaction, so a failed event write rolls back the state change. The request event table begins empty when migration 004 runs; earlier transitions are not reconstructed. The audit table starts empty on migration 006 and records subsequent actions atomically. There is no separate execution queue: the gateway does not run actions. The indexes support due-delivery, pending-expiry, and per-client newest-first listing scans.
Client IDs and bootstrap keys live in CLIENT_KEYS in the server environment. Issued keys live in SQLite as hashes and are accepted only while their client ID remains configured and their optional expiry is in the future. The bootstrap key can administer issued keys and read its client's audit feed; issued keys cannot manage keys, read audit events, or elevate their scopes. TELEGRAM_ROUTES maps each client ID to its current destination chat and approver allowlist. The database stores the client ID on each request and the chat ID only after successful delivery. Rotating a bootstrap key for the same ID preserves request ownership and issued-key access.
State transitions
Decision and execution are separate fields:
Decision: pending ──► approved | rejected | expired | cancelled
Execution: unclaimed ──► claimed ──► succeeded | failedOnly approved + unclaimed can be claimed. Expiry applies while a request is pending; a decision made before the deadline stays approved even if the caller claims later. A claimed request with no result has an unknown external outcome. It never becomes claimable again automatically, because the caller might have performed the action before crashing.
Trust and failure boundaries
- Each key authorizes only its client ID. Issued keys additionally need the route's scope, and revocation is checked on each request. Reads, cancellation, claims, and results for another client's request return 404.
/healthand/readyare public and return no credentials. Each client's configured Telegram chat and numeric approver allowlist control its button decisions; usernames and forwarded messages do not grant authority. - The immutable stored proposal tells the approver what was approved. The caller must use those same parameters when executing. Action credentials stay in the caller's application, and no HTTP field is interpreted as a command, URL to call, script, or callback to execute.
- SQLite writes settle decisions and claims before external follow-up work. Telegram message edits are best effort; a failed edit does not undo a decision. Polling offsets advance after processing, so replay after a crash is safe because decision updates are conditional.
- A crash between Telegram accepting a send and SQLite storing its chat and message IDs can cause a duplicate message on retry. Only the recorded chat and message can decide the request. Delivery status and sanitized errors remain visible through the API.
- After a destination change, startup requeues still-pending delivered rows, rotates their callback references, and sends them to the new chat. Old buttons fail closed. A migration from the earlier schema also requeues still-pending delivered rows because their prior chat ID was not stored. Settled decisions stay unchanged; the original expiry deadline stays in force. Changes to a client's approver allowlist take effect for existing pending buttons after restart.
/readyrequires working storage and a ready Telegram poller/delivery worker; new creation is rejected while it is not ready. Existing requests can still be read. One instance is supported; multiple pollers or a pre-existing webhook for the bot token are rejected.- The gateway cannot guarantee exactly once execution of an outside effect. A caller should use an idempotency key with the target system where possible, retain the claim token until reporting, and reconcile an unknown outcome against the target before creating a new request.
Verification
npm test uses temporary SQLite databases, an injected clock, a fake Telegram transport, and an injected HTTP fetcher. It covers lifecycle and expiry boundaries, restart recovery, owner-scoped idempotency and authorization, filtered request listing and pagination, event ordering and atomic writes, scoped-key authorization and revocation, API validation, Telegram authorization and callback binding, delivery retries and failure, polling offset replay, claim races, and client waiting. npm run test:coverage prints a source coverage report. Tests never use a real bot token or contact Telegram.