Skip to content

Latest commit

 

History

History
268 lines (201 loc) · 8.59 KB

File metadata and controls

268 lines (201 loc) · 8.59 KB

Command Proposals API

The proposal API lets an external client suggest custom-command changes without applying them immediately. Each proposal is stored in SQLite and posted to a configured Discord review channel. A staff member must approve or reject it, unless approval is disabled — see Disabling approval.

Approval writes through the configured command repository. This works with both the default local SQLite provider and the optional GitHub provider.

Configuration

Enable proposals in config.json:

{
  "proposals": {
    "enabled": true,
    "review_channel_id": "123456789012345678",
    "max_pending": 5,
    "ttl_hours": 72,
    "rate_limit_per_minute": 100,
    "db_path": "./data/proposals.db"
  }
}

Set COMMAND_API_KEY in .env. The proposal and command-read endpoints share the same key and rate-limit budget.

The key must be at least 32 characters; startup refuses a weaker one. Generate it with:

openssl rand -hex 32

The proposal store records reviewer identities and review message IDs from Discord; see Data storage for the encryption-at-rest obligations that come with it.

Deployment security

The built-in HTTP server speaks plain HTTP. The default http.host of 127.0.0.1 keeps it unreachable from the network, which is the right choice when the proposer client runs on the same machine.

If the API must be reachable from another host, do not bind it to a public interface directly — put a TLS-terminating reverse proxy in front of it and keep the bot on localhost:

server {
    listen 443 ssl;
    server_name commands.example.com;

    ssl_certificate     /etc/letsencrypt/live/commands.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/commands.example.com/privkey.pem;

    location /api/ {
        proxy_pass http://127.0.0.1:9010;
    }
}

Caddy terminates TLS with even less configuration: commands.example.com { reverse_proxy 127.0.0.1:9010 }.

Without TLS the X-API-Key header crosses the network in plaintext and can be replayed by anyone who observes it.

Behind a reverse proxy every connection arrives from the proxy's address, so set http.trusted_proxies in config.json to the proxy's address (usually ["127.0.0.1", "::1"]) — otherwise the failed-auth throttle described below collapses all clients into one shared bucket that any abuser can exhaust. With a trusted proxy configured, the client identity comes from X-Forwarded-For (rightmost untrusted entry). X-Forwarded-For from any source NOT in trusted_proxies is ignored, so a directly reachable client can never spoof its identity.

Authentication

Send the key in every request:

X-API-Key: your-shared-key

Keys are compared in constant time. Missing or incorrect keys return HTTP 401. After 10 failed attempts within a minute from the same source IP, every request from that source returns HTTP 429 until a minute passes without further failures.

Read endpoints

GET /api/v1/commands
GET /api/v1/commands?detail=full
GET /api/v1/commands/{name}
GET /api/v1/commands/search?q=welcome

Summary responses contain command names, descriptions, and page presence. Full, single-command, and search responses include exact raw repository bodies. Clients should read the current raw body immediately before constructing guarded patch edits.

Search accepts:

  • q or query: required, at most 256 characters and 16 terms
  • limit: 1–50, default 10
  • min_score: 0–100, default 45

Read errors use { "error": "..." }.

Proposal endpoint

POST /api/v1/commands/proposals
Content-Type: application/json
X-API-Key: your-shared-key

The body is limited to 256 KiB:

{
  "operation": "create",
  "command_name": "welcome",
  "command": {
    "format": 2,
    "name": "welcome",
    "description": "Welcome information for new members",
    "blocks": [
      { "type": "heading", "text": "Welcome" },
      { "type": "text", "text": "Start here to learn about the community." }
    ]
  },
  "rationale": "Add a concise starting point for new members",
  "proposer": "assistant"
}

Supported operations:

  • create: requires a full format-2 command body.
  • delete: removes an existing command after approval.
  • patch: requires a non-empty edits array.
  • edit: accepted as an alias for patch; full-body edits are rejected.

The response shape is always:

{
  "status": "staged",
  "message": "proposal staged for staff review",
  "proposal_id": "0123456789abcdef0123456789abcdef"
}
Status HTTP Meaning
staged 201 Stored and posted for review
applied 200 Written immediately because approval is disabled
duplicate 200 A matching pending proposal already exists
too_many_pending 429 The review queue reached its configured limit
invalid 400 Request or resulting command is invalid
conflict 409 The proposal does not match current repository state
unavailable 503 Repository, proposal service, or review channel is unavailable
error 500 Internal failure

Semantic patches

Edits are applied in order to a deep copy of the current raw command body. The result is validated before staging and re-applied to the latest authoritative body during approval.

Replace text

The old text must occur exactly once in the selected target:

{
  "operation": "patch",
  "command_name": "welcome",
  "edits": [
    {
      "type": "replace_text",
      "old": "Start here",
      "new": "Begin here"
    }
  ]
}

A target may select a command, page, or block:

{
  "type": "replace_text",
  "target": { "kind": "page", "page": "getting_started" },
  "property": "description",
  "old": "Old description",
  "new": "Updated description"
}

Pages can be referenced by zero-based index, normalized name, or title. Blocks can be referenced by zero-based index or by the exact name of a field block.

Set a property

old is an optimistic-concurrency guard. null also matches an absent optional property:

{
  "type": "set_property",
  "target": { "kind": "command" },
  "property": "thumbnail_url",
  "old": null,
  "new": "https://cdn.example.com/welcome.png"
}

Insert an item

{
  "type": "insert_item",
  "item_type": "block",
  "target": { "kind": "page", "page": "getting_started" },
  "position": "end",
  "item": { "type": "small", "text": "Updated regularly" }
}

item_type is page or block. Positions are zero-based indices or "end".

Remove an item

Removal requires the complete current item as an old guard:

{
  "type": "remove_item",
  "target": { "kind": "block", "block": 0 },
  "old": { "type": "heading", "text": "Welcome" }
}

Move an item

{
  "type": "move_item",
  "target": { "kind": "page", "page": "faq" },
  "old": {
    "name": "faq",
    "title": "FAQ",
    "blocks": [{ "type": "text", "text": "Common questions." }]
  },
  "position": 0
}

Moves require the complete current item as an old identity guard, preventing an index from silently retargeting another page or block before approval.

Approval safety

Approval:

  1. Atomically claims the pending proposal in SQLite.
  2. Re-reads the current authoritative command.
  3. Re-applies semantic guards.
  4. Uses the repository revision for a conditional write.
  5. Retries once only for a revision race.
  6. Updates the live catalog and Discord registration.

A timed-out remote write is treated as indeterminate and is not retried blindly. Staff should reload the repository before submitting another proposal.

Disabling approval

Set require_approval to false to apply valid proposals on submission instead of staging them:

{
  "proposals": {
    "enabled": true,
    "require_approval": false
  }
}

Submissions then return applied with HTTP 200 instead of staged with 201. Every step of the approval sequence above still runs — the claim, the re-read, the semantic guards, the conditional write, and the registration update — so a proposal that would have been rejected as invalid or conflicting is still rejected. Only the human gate is removed.

review_channel_id becomes optional. If it is set, each applied proposal is still posted to that channel as a record, without approve and reject buttons. Proposals remain in the SQLite store, resolved by Automatic approval, and repository commits are still attributed to the proposer.

Anyone holding COMMAND_API_KEY can create, edit, and delete commands unattended in this mode. Use it only where the key is as trusted as a staff member. The default is true, and a host that omits the setting requires approval.