Skip to content

Latest commit

 

History

History
580 lines (492 loc) · 27.1 KB

File metadata and controls

580 lines (492 loc) · 27.1 KB

Native control API v1

For an introduction to the control-listener/native-session relationship and a working client flow, see the native gateway practical guide.

Status: implemented invitation management, registration, health, diagnostics, account password replacement, owner-scoped automation management, account avatar state, configured-operator capability management, automated-account provisioning, same-origin onboarding, and the frozen M11 sync/command extension with M12A automation and M12B provider control

The native control listener is the first small piece of the future client and integration gateway. It is not a second chat backend: every mutation uses the same account authority, invitation domain, and serialized SQLite worker as IRC.

Configuration

The generalized default remains operator-provisioned accounts with no HTTP listener:

IRC_CONTROL_LISTEN unset
IRC_ACCOUNT_REGISTRATION=operator

The same settings live under [listeners] and [admission] in the versioned TOML file; see server configuration. Environment variables shown here are explicit overrides and remain useful for local probes.

Enable the listener and invitation redemption explicitly:

IRC_CONTROL_LISTEN=0.0.0.0:8098 \
IRC_ACCOUNT_REGISTRATION=invite \
IRC_REQUIRE_AUTHENTICATION=true \
IRC_TLS_CERT=/etc/letsencrypt/live/irc.example.net/fullchain.pem \
IRC_TLS_KEY=/etc/letsencrypt/live/irc.example.net/privkey.pem \
cargo run --locked

operator and invite are the only registration modes. Invite mode refuses to start without a control listener. Anonymous IRC admission remains a separate choice controlled by IRC_REQUIRE_AUTHENTICATION; a private invite-only deployment will generally enable both invite registration and required IRC authentication.

The control listener uses the server's existing TLS identity. Plain HTTP is accepted only on a loopback address for local development; unlike the IRC listener, no configuration override can expose remote plaintext credentials or bearer tokens. The configured TLS certificate therefore has to cover the host name clients use for both services.

When invite registration is active, visit /onboard/ on the control listener. The page is compiled into the server binary and loads no third-party scripts, fonts, images, analytics, or media. Operator mode does not serve the page.

Wire contract

Request and response bodies are JSON except for the bounded avatar upload and PNG retrieval described below. JSON clients should send Content-Type: application/json. The response header Cache-Control: no-store is present on API JSON so credentials, invitation tokens, and registration results are not intentionally cached.

GET /v1 is public and reports the API version, server/network names, and active account-registration mode.

GET /v1/health is public and returns only a readiness state after a two-second bounded round trip through the serialized SQLite worker:

{"api_version":"1","status":"ready"}

It returns HTTP 503 with status set to unavailable if the worker cannot answer. The endpoint deliberately omits names, versions, counters, epochs, and presence. It proves that the live process can execute a storage command; it is not a full SQLite integrity scan.

GET /v1/diagnostics uses HTTP Basic authentication and requires the narrow diagnostics.read global capability. Its response contains only readiness, uptime, schema version, the client-visible content epoch, the configured connection ceiling, effective rate policies, and aggregate accepted/rejected counters, including heartbeat timeouts. It contains no current connection count, account or installation identity, nickname, address, channel, direct-message relation, message, database path, or storage-size proxy. Cross-origin browser requests are rejected.

Grant the capability through the offline authority CLI while the service is stopped, then let curl prompt rather than putting a password on the command line:

IRC_CONFIG=/etc/telex/server.toml \
  telex-ircd account capability grant Operator diagnostics.read
curl --fail --user Operator \
  --resolve irc.example.net:8098:127.0.0.1 \
  https://irc.example.net:8098/v1/diagnostics

The grant is durable, metadata-audited, independently revocable, and preserved by content reset. Invite stewardship does not imply diagnostic access.

GET /v1/operator-accounts uses ordinary human HTTP Basic credentials and requires the authenticated account to appear in operators.accounts. It returns the authenticated operator, the bounded configured-operator list, and at most 256 human accounts with active/disabled state, current avatar presence, and their exact global capabilities. Bot and agent accounts are omitted. This is the only global account directory in API v1, and only configured operators may read it.

POST /v1/operator-capabilities accepts exactly one of the four existing human global capabilities:

{
  "account_name": "alice",
  "capability": "invite.issue",
  "granted": true
}

The complete set is invite.issue, invite.revoke, account.automation.provision, and diagnostics.read. The target must be a human account. Grants to disabled accounts are rejected; existing grants may still be revoked. The store transaction rechecks that the operator actor is active and records every changed grant or revocation with that actor and target in the existing metadata-only authority audit. Repeating the same state is an idempotent success and does not invent another change record.

Configured operator authority is intentionally outside this delegable set. This endpoint cannot add operators or manage passwords, account status, automation processes, channel roles, or any unrecognized privilege. Edit the versioned configuration and restart the daemon to change who may use it.

DELETE /v1/operator-accounts/<account>/avatar uses the same configured- operator authentication and removes only the target human's current avatar. It works for disabled humans, is idempotent, and reports whether state changed. There is no operator route for setting or choosing another account's image.

POST /v1/automated-accounts requires HTTP Basic authentication by a human account holding account.automation.provision. It atomically creates a bot or agent, one labeled server-generated app key, and its mandatory disclosure manifest:

{
  "account_name": "HelperAgent",
  "key_label": "production adapter",
  "kind": "agent",
  "purpose": "Answers explicit questions in approved channels",
  "processing": {
    "boundary": "external",
    "processor": "Example Model Service"
  },
  "data_handling": "Granted text is sent to the named service; provider retention may apply"
}

local processing requires processor to be absent or null; network and external require a bounded processor name. Success returns 201 with the account ID, app-key ID, account/key-id authentication ID, label, one-time ak1... key, and normalized manifest. The server stores only the key's SHA-256 digest and sends Cache-Control: no-store. Invalid input returns 400, an existing account name returns 409, and missing authority returns 403. The operation has its own per-source administration-rate bucket, and the capability is rechecked transactionally.

This endpoint does not list accounts, grant a channel, store provider secrets, or make an account discoverable. Automated credentials remain unavailable until a channel grant exists. A granted credential may use the native gateway; direct IRC login remains disabled so it cannot widen a narrow native grant.

An account-provisioned automation manifest also records that human as its durable manager. GET /v1/automation-management returns only automation owned by the authenticated human, its non-secret app-key metadata, and channel names where that human is currently owner/operator. It separately reports whether the human still holds account.automation.provision; losing that capability prevents new accounts but does not orphan automation already owned.

POST /v1/automated-accounts/<account>/app-keys accepts one bounded label and returns the new account/key-id authentication ID and random ak1... key once. DELETE /v1/automated-accounts/<account>/app-keys/<key-id> revokes and scrubs one key, invalidates its pending native tickets, and closes only sessions bound to that device ID. Both operations require the authenticated human to be the stored automation owner; unknown and foreign automation collapse to the same unavailable result.

GET /v1/automation-channels/<percent-encoded-channel> returns the current manifested grants, chatter switch, guardrails, pause, cooldowns, and shared policy revision only to a current human owner/operator. It performs the role check and snapshot read as one serialized store command. Mutations continue to use their existing compare-and-set endpoints below.

POST /v1/channel-automation-grants requires a human channel owner or operator and updates one automated account's grant using the current channel-policy revision:

{
  "channel": "#agents",
  "expected_revision": 4,
  "account_name": "HelperAgent",
  "grant": {
    "permissions": [
      "live.human.read",
      "history.read",
      "message.reply"
    ],
    "history_window_seconds": 604800,
    "invocation_context_events": 0
  }
}

Set grant to null to remove it. Stable permission names are invocation.receive, live.human.read, live.automation.read, history.read, message.reply, message.publish, roster.read, content.consume, content.bulk, and stage.publish. History authority requires an explicit window from 60 through 34,560,000 seconds. Bulk content requires ordinary content consumption, and reply authority requires a possible delivered cause. roster.read releases only an on-demand page of current joined nicknames with their NAMES prefix and server-asserted bot state; it grants no account or device metadata and no presence subscription. stage.publish is deliberately not a native chat operation. It authorizes only the separate ephemeral Stage transport, exposes no channel events, and does not imply message.publish. invocation_context_events is required on every non-null grant and must be an integer from 0 through 10. A positive value requires invocation.receive; a grant without that permission must use 0. Zero means the automation receives the visible addressed request with no preceding events.

POST /v1/channel-automation-chatter changes the independent channel switch:

{
  "channel": "#agents",
  "expected_revision": 5,
  "enabled": true
}

Applied or no-op updates return 200; a stale revision returns 409. The response names current_revision, allowing a client to reload policy rather than silently overwriting a concurrent change. Authorization is checked before the target-account lookup and rechecked in the serialized transaction, so the surface neither becomes an automation directory nor trusts a stale runtime snapshot. Applied changes immediately refresh the complete native policy and the compact revision-tagged IRC policy snapshot; grant removal ejects every connected session from that channel. The native gateway rechecks this durable policy at admission, replay, live delivery, publication, acknowledgement, and revocation boundaries.

POST /v1/content-providers requires a human channel owner or operator. The target must be an active bot or agent with the channel's current message.publish grant:

{
  "channel": "#packages",
  "account_name": "ArchiveBot",
  "expected_revision": null,
  "display_name": "Example Archive",
  "request_base_url": "https://files.example/request/",
  "data_handling": "The provider ordinarily records downloader addresses for seven days",
  "bulk_enabled": false,
  "dcc": {
    "advertised_ipv4": "203.0.113.20",
    "first_port": 45000,
    "last_port": 45015
  }
}

Creation requires expected_revision: null and returns 201. Updates require the exact current positive revision and return 200; omitting it or racing a change returns 409. The base must end in / and use HTTPS, except that explicit loopback HTTP is accepted for local development. dcc is optional. When present, it fixes one IPv4 address and an inclusive range of at most 256 nonzero ports; provider software may select a port from that range but cannot change the advertised address or widen operator policy. Success returns the provider ID, conversation ID, account/display names, base URL, disclosure, optional DCC endpoint, bulk and pause flags, and current revision. This operator response is the only catalogue-side API projection containing the request-base URL or DCC endpoint.

POST /v1/content-provider-pause uses the same human authority:

{
  "provider_id": "019...",
  "expected_revision": 1,
  "paused": true
}

Pause is revision-safe. A paused provider disappears from search and cannot publish packages, receive new capabilities, or redeem an outstanding token. Resuming does not recreate deleted or reset state.

POST /v1/content-capabilities/redeem is the provider-facing exchange endpoint. It authenticates an automated provider account/key-id and app key with HTTP Basic and accepts one exact bearer token in JSON:

{"token":"ct1.<43 base64url characters>"}

Success atomically consumes the five-minute token and returns the capability, provider and package IDs, channel-scoped requester pseudonym, requester kind, individual/bulk mode, decimal-string byte size, and issue/expiry timestamps. It contains no requester account/name/device, channel ID, address, or unrelated membership. Malformed, expired, used, wrong-provider, paused, and revoked tokens all return the same 404 capability_unavailable; invalid provider credentials return the ordinary generic 401 response.

The provider's public HTTPS endpoint is separate from this control endpoint: it receives the ct1 path from the client, redeems it server-to-server, and then decides whether and how to send its own bytes. The IRC daemon never fetches that URL. The complete exchange and fixed budgets are in external content references.

A traditional DCC provider does not receive the bearer token. Its assigned native session offers an approved listener port through content.transfer; Telex then atomically consumes the same capability by ID before sending the legacy CTCP offer. The control redemption response is therefore not an alternate way to accept an in-flight DCC request.

POST /v1/account-registrations is public only in invite mode:

{
  "invite_token": "v1.<invite-id>.<secret>",
  "account_name": "alice",
  "password": "a long account password"
}

Success returns 201 with only the stable account_id. Malformed JSON, invalid account/password input, duplicate names, and malformed, unknown, mismatched, expired, revoked, or exhausted tokens all return the same 400 registration_unavailable response. In operator mode the route returns 404. If the server has an admission.initial_channel, successful redemption adds it to the new account in the same transaction. The response remains unchanged.

GET /v1/account validates an existing human's HTTP Basic credentials and returns the display account name, current avatar revision, current nickname color, whether that same account is named in the non-delegable configured-operator list, and its two invitation authorities:

{
  "api_version": "1",
  "account_name": "alice",
  "avatar_revision": null,
  "nickname_color": "automatic",
  "configured_operator": false,
  "invitation_permissions": {"issue": true, "revoke": true}
}

It is the login check for the same-origin account page. It does not create a cookie or server session, return an account ID, enumerate other capabilities, or weaken the transactional checks performed again by each mutation endpoint. configured_operator reveals only the authority of the already-authenticated account; the human directory remains available exclusively through GET /v1/operator-accounts.

PUT /v1/account-avatar lets an authenticated human set their own avatar. Its body is one static PNG, JPEG, or WebP rather than JSON, and the corresponding Content-Type must match the bytes. Input is capped at 5 MiB. The server applies orientation, center crops, resizes, and returns JSON describing the canonical 256-by-256 PNG revision. DELETE /v1/account-avatar removes that state idempotently.

PUT /v1/account-nickname-color replaces the authenticated human's current account-visible nickname color:

{"nickname_color":"lilac"}

The allowed values are automatic, plain, green, sky, lilac, pink, amber, aqua, moss, and coral. automatic removes durable presentation state and lets capable clients derive a deterministic palette entry from the account identity. Success reports the selected value and whether state changed.

GET /v1/avatars/<account> returns one canonical image/png to any authenticated human, with a SHA-256 ETag and Cache-Control: private, no-cache. Missing accounts and accounts without an avatar share 404 avatar_unavailable; automated app keys are rejected. The read accepts either the control listener's exact same origin or an exact origin in websocket.origins, with CORS limited to authenticated GET. Every other control route remains same-origin. Existing accounts expose their current X-Telex-Nickname-Color on both image and avatar-unavailable responses so one bounded authenticated lookup can project both pieces of current identity. See account avatars and nickname colors.

POST /v1/account-password lets an authenticated human replace their own password:

{"new_password":"a new account password"}

The current password remains in HTTP Basic. A remembered-device bearer is deliberately insufficient for this operation. Success atomically stores the new Argon2id verifier, revokes and scrubs every remembered-device verifier, and disconnects every active session for that account. The old password and remembered bearers no longer authenticate; account identity, installation tokens, and cursor state remain unchanged. Automated accounts are rejected because they use independently revocable app keys instead. See account password changes and recovery.

POST /v1/account-remembered-devices requires actual human account/password authentication and accepts a valid installation token plus a bounded device label:

{
  "installation_token": "dr1.<installation-uuid>.<secret>",
  "label": "web app - Android"
}

Success returns 201 with that installation UUID, the authentication ID account/<installation-uuid>, and the only plaintext copy of a new random rd1... credential. Issuing again for the same installation rotates its prior credential. The server stores only a SHA-256 digest. An rd1... credential cannot call this endpoint.

GET /v1/account-remembered-devices accepts either the human password or an active remembered-device credential and returns at most the account's 16 active installation-bound entries with label and creation time. DELETE /v1/account-remembered-devices/<installation-uuid> revokes and scrubs one entry, closes live IRC/native/Stage sessions bound to that installation, and preserves the separate delivery cursor. These endpoints are the server half of remembered web-device login.

POST /v1/invitations requires HTTP Basic authentication using an existing human account name and password. The Basic username is the same bare account name used for IRC SASL.

{
  "valid_seconds": 86400,
  "max_uses": 1
}

The authenticated account must hold invite.issue. A successful 201 response contains the invitation ID, expiration time in Unix milliseconds, use quota, and the only plaintext copy of the bearer token. The token cannot be recovered later.

DELETE /v1/invitations/<invite-id> uses the same authentication scheme and requires invite.revoke. It returns whether the invitation was newly revoked or was already revoked. Issue and revoke remain deliberately separate grants.

API v1 has no cookies, invitation listing, global automation listing, or general-purpose database endpoint. Its one durable human login mechanism is the explicitly opted-in, installation-bound, independently revocable rd1... bearer above; it is not a cookie or a server session. The one global account projection is the configured-operator-only, human-only bounded directory above; ordinary users cannot enumerate it. The automation index remains limited to durable ownership and current channel operator authority. A richer client authentication mechanism can be added when the gateway grows, without changing the account/installation/app-key model or inventing a parallel authority system.

The implemented M11 extension contract is documented in the native sync and command API. It exchanges the same account password or automation app key for a memory-only, single-use, optionally attenuated ticket rather than adding a durable login session or browser cookie.

Browser onboarding and account controls

The bundled page fetches public server status and submits JSON to the same-origin registration route. It never puts an invitation in a query string or fragment. Account-name rules and password byte bounds are checked locally for useful feedback, while the server remains authoritative and keeps its generic public failure response.

Invitation and password fields are cleared after successful submission and on page navigation. The success view shows only the account name and reminds the user to use that account and password on every client; it does not redisplay or store the password. JavaScript and browser memory do not provide a forensic zeroization guarantee, so this is ordinary secret minimization rather than such a claim.

The bundled /account/ page is available regardless of registration mode and uses the same local-only assets and response hardening. It checks credentials with GET /v1/account, holds them only in JavaScript memory for the life of the tab, and clears its references and fields on logout or navigation. Every human can manage their canonical avatar and change their password. Only invite.issue holders see the fixed 24-hour, single-use invitation action, and invite.revoke is checked independently. The server re-authenticates and re-authorizes every action; “logged in” is a page state, not a durable bearer session.

The bundled /automation/ page follows the same local-only, sharp-edged visual language in a wider dedicated workspace rather than another account modal. It uses HTTP Basic reauthentication for every read and mutation, retains the human password only for the tab lifetime, renders remote text through DOM text nodes, and shows app keys only in a clearable one-time result. Its account view covers owned manifests and app-key lifecycle; its channel view covers exact durable grants, chatter, pause, and operating budgets. Every active app key remains in the management projection; only older revoked-key history can be truncated. Content-provider registration remains a separate interface rather than being folded into account ownership.

The generated reference web client integrates those same controls as a third management section only when its authenticated /v1/account summary reports configured_operator: true. It reuses the live tab's memory-only account credentials and does not probe the privileged directory to discover authority or ask for a second login. The bundled /operator/ page remains a standalone fallback using the same tab-scoped Basic contract and local-only assets. Both surfaces show the bounded human directory, four explicit capability toggles, and narrow avatar-clear action only. Remote names are rendered as text. The server reauthenticates and checks configured-operator authority for every request, and changed grants remain actor-attributed in the durable authority audit.

The document policy defaults all content sources to none and selectively allows only same-origin scripts, styles, API connections, avatar Blob URLs, and form actions. External content and inline code remain blocked. Mutation requests carrying an Origin header are accepted only when its scheme and host exactly match the request; cross-origin forms are rejected, while non-browser native clients may omit the header. The authenticated avatar read above is the sole narrow CORS response.

Bounds and privacy

  • Request bodies are capped at 4 KiB except the avatar upload's explicit 5 MiB limit.
  • Registration and invitation mutations use the configured invitation token bucket per TCP source; authenticated-control attempts use a separate twenty-per-minute, twenty-burst source bucket plus the server-wide authentication budget.
  • Automated-account provisioning, channel-automation policy mutations, operator capability changes, and avatar mutations use administration-class per-source buckets. Image decoding has its own two-worker bound. Password hashing shares the bounded secret-worker pool with invitation redemption.
  • Content redemption has a separate twenty-per-minute, twenty-burst rotating per-source bucket, then uses the same server-wide bounded authentication and password-verification path. Provider registration and pause use the ordinary automation-policy mutation boundary.
  • The limiter stores only a process-random keyed derivative of the source address. Its key and all counters rotate after one minute, the map is capped at 2,048 entries, and nothing is written to SQLite or logs.
  • Proxy forwarding headers are ignored. A reverse proxy therefore makes its address the source for throttling unless a future explicitly trusted-proxy design says otherwise.
  • TLS negotiations are limited to sixteen concurrent ten-second handshakes, so one client that never sends a handshake cannot serialize the listener. An accepted HTTP connection also has a 30-second read lifetime to bound stalled headers, bodies, and idle keep-alive clients.
  • Account, invitation, and password hashing runs in bounded blocking pools, not on the SQLite worker. The database transaction still rechecks authority, secret hash, expiration, revocation, and quota before committing.
  • Unknown accounts perform a dummy password verification; unknown or malformed invitation tokens perform a dummy invitation-secret verification.
  • The control listener shares the server-wide connection ceiling and stops accepting mutations before storage shuts down.

The complete throughput policies and aggregate counter boundary are documented in runtime rate limits.

This is transport security and server-side access control, not end-to-end encryption. The server process receives plaintext credentials and invitation tokens and the host operator controls the executable and database. Use TLS, keep tokens out of URLs and chat logs, and treat a multi-use token as a shared bearer capability.

Existing IRC clients continue to authenticate through SASL after account creation. The page is only an onboarding adapter over this API, not a new chat protocol or an operator console. The work-in-progress IRCv3 account-registration draft is intentionally deferred: it discourages production use and offers no invitation-token field, so the server does not misrepresent invitations as email or verification data.