A Telegram notifier for Mimir, the AI-settled prediction market on Stellar. It polls Mimir's two Soroban contracts for new on-chain events and posts them, human-readable, into one or more named chats or channels:
🆕 New claim #7
Category: crypto
Creator: GBMGZ…IR2Y
ledger 4226691 · tx
⚔️ Claim #7 challenged
Stake: 2.0000000 USDC
Challenger: GDZCB…X4UH
ledger 4226692 · tx
⚖️ Claim #7 resolved — winner: challengers
Confidence: 100%
Onchain smoke — challengers awarded so the payout pull can be exercised
ledger 4226728 · tx
Built with grammy and
@stellar/stellar-sdk. Reads only —
it holds no keys and signs nothing.
| Contract | Events it notifies on |
|---|---|
mimir-market |
claim_created, claim_challenged, claim_resolved, claim_cancelled, market_settled, challenger_paid, fee_claimed, withdrawal, withdrawal_pending |
mimir-squad |
market_created, deposited, withdrawn, resolved, claimed, fees_claimed |
Admin events (oracle_changed, ownership_transferred, fee_policy_*,
fee_accrued, agent_attributed) are decoded far enough to be recognised and
then skipped — they are logged, not posted.
Message @BotFather on Telegram, send /newbot, follow
the prompts, and copy the token it gives you (123456789:AA…).
- Private chat: message @userinfobot; it replies with your numeric id.
- Group: add your bot to the group, send any message, then open
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesand readresult[].message.chat.id. Group and supergroup ids are negative (-1001234567890). - Channel: add the bot as an administrator with "Post messages" permission.
Either use the numeric id from
getUpdatesor, for a public channel, the@channelusername.
If your group has privacy mode
on (the default), the bot only sees messages that are commands or replies to it —
which covers /status and the operator controls below.
Set OPERATOR_TELEGRAM_USER_ID to the numeric user id returned by
@userinfobot to enable /pause and /resume. The notification
TELEGRAM_CHAT_ID is intentionally not accepted as authorization: in a group,
everyone can send messages from that chat. If this variable is omitted, existing
deployments continue unchanged and both operator commands are ignored.
cp .env.example .env # then fill in BOT_TOKEN and TELEGRAM_CHAT_ID
npm install
npm run dev # tsx, restarts on changeFor production (Node):
npm run build
npm startFor production (Docker):
docker build -t mimir-telegram-bot .
docker run -d \
--name mimir-bot \
--env-file .env \
-v $(pwd)/data:/app/data \
mimir-telegram-bot.env.example ships with the live Stellar Testnet contract ids, so the only two
values you must supply are BOT_TOKEN and TELEGRAM_CHAT_ID. To split traffic,
set TELEGRAM_MARKET_CHAT_ID and/or TELEGRAM_SQUAD_CHAT_ID; each overrides the
legacy destination for that contract, while an omitted override falls back to
TELEGRAM_CHAT_ID. Every other
variable is documented inline there. A missing or malformed value aborts startup
with all the problems listed at once — the bot never boots into a state where it
looks healthy but notifies nobody.
| Command | What it does |
|---|---|
/start |
What the bot is |
/help |
Same, plus the command list |
/status |
Chain tip, the RPC's retained-history floor, the chain clock skew (newest chain close time the bot has seen, against its own clock), both watched contract ids, the last ledger an event was seen in per contract, the persisted cursor, poll/send/skip counters (plus messages dropped by a shutdown drain) and the last error |
/contracts |
The two contract ids this bot watches (mimir-market, mimir-squad) and a stellar.expert link for each. Reads only from config, so it answers the same during a cold start, a run of RPC failures, or between restarts — unlike /status, there is nothing here that can be "unhealthy" |
/preview |
Previews channel notification formatting for mimir-market or mimir-squad without affecting cursors or poller state |
/pause |
Operator only. Stops scheduling new poll cycles; a scan already in progress may finish and persist its normal cursor |
/resume |
Operator only. Schedules the next poll cycle immediately, without changing or replaying cursors |
Commands from a user other than OPERATOR_TELEGRAM_USER_ID receive no control
response and cannot mutate poller state. Repeated /pause or /resume commands
are idempotent. Control state is process-local: a restart resumes polling and
loads the existing version-1 cursor file.
/status is for a human in the chat. For a supervisor, a dashboard, or a shell
on the box, the poller also writes the same facts as JSON to STATUS_FILE
(default data/status.json) after every cycle, and on start and stop:
npm start -- --status # or: node dist/index.js --status{
"schemaVersion": 1,
"generatedAt": "2026-08-21T10:00:00.000Z",
"uptimeMs": 3600000,
"running": true,
"network": "testnet",
"rpcUrl": "https://soroban-testnet.stellar.org",
"chatId": "-…7890",
"pollIntervalMs": 30000,
"maxNotificationsPerCycle": 20,
"cycles": 120,
"lastPollAt": 1755770400000,
"lastSuccessAt": 1755770400000,
"latestLedger": 4226733,
"oldestLedger": 4105773,
"notificationsSent": 11,
"notificationsFailed": 0,
"eventsSkipped": 3,
"consecutiveFailures": 0,
"lastError": null,
"targets": [
{
"source": "market",
"contractId": "CDV6JXIJCALSXQELCS6YUEWJWG5DFXQK5PJ5I7MWI6KVMQJBC5DLPKZI",
"cursor": "0018276211125911551-4294967295",
"lastEventLedger": 4226729,
"lastError": null
}
]
}--status reads the file only — it never contacts Telegram or the RPC — so it is
safe to run from a health check or a cron job while the bot is running. It exits
0 when a snapshot was read and 1 when there is none or it is not valid JSON.
What is deliberately not in it. The snapshot is built from an allowlist of
fields, so nothing can leak by accident. It never contains the bot token, a
private key, or a payment proof. The chat id is redacted to its sign and last
four digits (-…7890), and every string that comes from outside the process —
RPC errors, Telegram errors, cursors — is whitespace-collapsed and truncated
(MAX_ERROR_CHARS, 300) so a hostile or chatty endpoint cannot write an
unbounded blob into the file or into a log line. The write is atomic
(write-then-rename), so a reader never sees a half-written document.
Reading it in a health check. running: false means the process stopped
deliberately (SIGINT/SIGTERM) or has not started; consecutiveFailures > 0 with
a fresh lastPollAt means the RPC is failing but the loop is alive; a
generatedAt that stops advancing means the process is wedged or gone. The
chain remains the source of truth — this file reports on the reader, it is not a
substitute for reading the chain.
The chain reader runs standalone. Testnet's Soroban RPC is public and unauthenticated, so this needs nothing but the contract ids:
npm run scan # both contracts, from the RPC's retained floor
npm run scan -- --pages 40 # walk further
npm run scan -- --show 20 # print 20 decoded events per contract
npm run scan -- --from 4226500 # explicit start ledger
npm run scan -- --json # one mimir-scan-v1 JSON document on stdout
npm run scan -- --json --show 20 # JSON including 20 decoded events per contractHuman mode prints the ledger window, an event-name histogram, and the decoded
payloads. With --json, stdout is a single mimir-scan-v1 document (bigints as
decimal strings) and progress goes to stderr, so npm run scan -- --json | jq
stays valid. Each target reports its startLedger and startClamped, so it is
clear when a requested --from was moved up to the retained floor. Neither mode
prints bot tokens or signing keys — the scanner never holds them. This is how the
decoder was verified against the live deployment.
MIMIR_PROFILE=mock (or the scanner's --mock flag) fills in any config value
the environment leaves unset with a local, loopback-only Soroban mock:
fixture contract ids, http://127.0.0.1:8420 RPC, and an isolated cursor file
at data/cursor.mock.json so a drill can never touch the real bot's position.
Explicit environment variables always win, any other profile name fails fast at
startup, and nothing here needs a bot token, Telegram credentials, or Testnet.
npm run mock:rpc # serve the fixture scenario on 127.0.0.1:8420
npm run scan:mock # scanner --mock: decode the scenario, no credentials
npm run mock:poll # dry run: mock RPC + real poller, sends are logged
npm run mock:poll -- --fail-events error # inject in-band JSON-RPC failures
npm run mock:rpc -- --stale-cursor # reject cursors once the poller has one
npm run mock:poll -- --malformed # append an undecodable event (must skip, not crash)
npm run mock:poll -- --port 0 # ephemeral port (any entry point accepts it)Failure kinds are error, http-500, rate-limit, and stale-cursor, with
the shorthands --fail-rpc, --rate-limit, --stale-cursor for getEvents
and --fail-health <kind> for getHealth. The mock enforces the real RPC's
request rules — mutually exclusive startLedger/cursor, the retained floor as
an error rather than an empty page, bounded error messages — and the dry run
exercises the poller's cursor-safety, restart, and bounded-log guarantees end to
end. npm test covers all of it (tests/mock-*.test.mjs); run just those with
npm run test:mock.
Soroban's getEvents is not eth_getLogs, and the difference is the whole
design of src/stellar/events.ts:
- Paging is by opaque cursor, not block range, so the walk is inherently sequential — there is no chunk fan-out to parallelise.
startLedger/endLedgerandcursorare mutually exclusive in one request.- The RPC keeps only a rolling window of events (~120,960 ledgers, roughly a
week, on Testnet). A
startLedgerbelow the retained floor is an error, not an empty result, so the floor is clamped fromgetHealth()first. - The window from
getHealth()is validated before the first request: an inverted or malformed window fails with a bounded error, a start ledger below the floor is clamped up to it, andnpm run scan -- --from <future>is refused rather than silently reading a different range. - An empty page does not mean the scan is finished. One request covers a
bounded slice of ledgers and returns whatever was in it — frequently nothing —
plus a cursor to continue from. Terminating on a short page (the correct
instinct for
eth_getLogs) silently yields zero events. Verified against the live deployment: reading the market contract from the retained floor takes 13 pages, 12 of which are empty, to reach the page holding all 11 of its events.
So the walk terminates on the cursor, never on the payload.
Events are also not a source of truth for current state — a claim's stakes and status come from the contract's own getters. This bot is a timeline, not an index.
The decoder is deliberately forward-compatible at the event boundary:
- Soroban event topics are read in declaration order, and non-topic fields are read from the event value map using their deployed snake_case names.
- A known event with a malformed topic, value, address, integer, or XDR value
becomes an
unknownevent.decodeEventnever throws into the poller, so one bad event cannot stop a scan or move a cursor based on a partial payload. - Events that are valid on-chain but unknown to this version are retained as
unknownfor bounded logs and are skipped for Telegram. They are not invented, retried, or treated as current contract state. - Amounts remain
bigintatomic USDC values until formatting; no floating-point conversion is used. Contract strings are clipped at the notification and diagnostic boundaries, and scanner output is bounded.
The compatibility promise is for the deployed event wire shape and the public decoded payload names above, not for arbitrary XDR or future contract fields. Adding an optional field is safe when the existing fields retain their names and types. Renaming a topic or changing a field type is a decoder compatibility change and must be deployed together with a recorded fixture and an operational note. The chain remains authoritative if the bot version cannot decode an event.
RPC configuration is read-only: STELLAR_RPC_URL must point to a Soroban RPC
endpoint, and STELLAR_NETWORK_PASSPHRASE controls network labeling for
explorer links. The client sends no signing material and creates no wallet.
Explorer path components are URL-encoded; custom STELLAR_EXPLORER_BASE_URL
values are supported without changing cursor or decoder compatibility.
The poller writes its resume position to data/cursor.json (write-then-rename,
so a crash mid-write cannot truncate it):
{
"version": 1,
"updatedAt": "2026-08-21T10:00:00.000Z",
"targets": {
"market": { "cursor": "0018276211125911551-4294967295", "lastEventLedger": 4226729 },
"squad": { "cursor": "0018276211125911551-4294967295", "lastEventLedger": 4226733 }
}
}On a cold start (no file) it begins START_LOOKBACK_LEDGERS behind the chain tip
rather than replaying the whole retained window into your chat. /pause and
/resume never edit this file; they only control scheduling, so the cursor
format remains version 1 and a restart does not preserve a pause. A graceful
shutdown flushes any cursor state that is still only in memory before the
process exits — see Graceful shutdown.
Tests never use this directory: they run against an ephemeral data directory created under the OS temp dir and removed afterwards (see docs/contributor-fixtures.md).
Deployment note: a flat file is fine for v0 but it must survive restarts. On
an always-on host, put data/ on a persistent volume (or point CURSOR_FILE
at one). On an ephemeral filesystem every restart is a cold start, and events
that happened while the bot was down are never posted. Swapping this for a real
KV store is a deliberate future step, not something this repo does today.
This process is meant to stay up for weeks, so a single failure never ends it:
-
A failed RPC call fails one contract's scan for one cycle. Its cursor is left untouched, so the next cycle resumes exactly where it stopped.
-
A partial notification batch commits the opaque RPC cursor after the returned page has been processed. Unknown events, events beyond
MAX_NOTIFICATIONS_PER_CYCLE, and sends that exhaust three bounded retries are counted as skipped or failed and are not replayed. Holding the cursor back would turn a revoked token or removed chat into an infinite replay, and recovery would flood the channel. A failed send is isolated to that routed chat and event; other events continue. Notifications are lossy on purpose — the chain is the record; the poller logs the sent/failed/skipped commit decision. A rejected inline keyboard (or a malformed MarkdownV2 payload) fails the same way as any other send. Events without a usable transaction hash are still sent, just without the explorer button. -
A corrupt cursor file is treated as a cold start rather than a crash. A valid but RPC-rejected stale cursor is never silently rewound: the target keeps that cursor, the error becomes visible in
/status, and scheduled retries or/resumeuse the same position. Recovery follows the incident runbook rather than replacing an opaque cursor with a guessed ledger. -
A request outside the retained window never reaches the RPC in one piece. The window (
oldestLedger…latestLedger) is validated fromgetHealth(); a resume cursor that the token itself places above the chain tip is refused before it is sent, with a bounded error in/statusand logs and the stored cursor left unchanged. A cursor below the retained floor is deliberately still forwarded: retention is the RPC's call, and its bounded stale rejection is what surfaces in/statuswhile the stored cursor stays put. A cursor shape the bot cannot read is forwarded too, so an RPC cursor-format change cannot wedge it. -
A burst is capped at
MAX_NOTIFICATIONS_PER_CYCLEmessages per cycle, spaced out, so Telegram's rate limiter is never the thing that takes the botdown. RPC, Telegram, and poller error text shown in
/statusor logs is compact, bounded, and the configured bot token is redacted. -
A long Stellar outage freezes the chain clock at the newest close time the RPC actually reported.
/statusandGET /healththen show a growing skew rather than a clock that keeps time on its own, so a stalled chain and a wrong local clock stay distinguishable. The clock is saved with the cursors, so a restart resumes it instead of reportingunknown, and an event without aledgerClosedAtnever counts as a chain time. -
An operator pause prevents new cycles but cannot cancel a bounded scan or Telegram retry loop already in progress. That cycle follows the normal cursor rules above;
/resumestarts the next cycle immediately. -
A status file that cannot be written is logged and ignored; it is an observability signal, never a reason to stop notifying. A corrupt snapshot makes
--statusexit1rather than print garbage. -
A shutdown stops scheduling, drops what has not been sent yet, waits at most
SHUTDOWN_TIMEOUT_MSfor the cycle in progress, and flushes any cursor state that is still only in memory — see Graceful shutdown.
SIGINT/SIGTERM starts a bounded drain rather than a hard stop:
- The poller stops scheduling cycles and reports itself as
stopping. - Notifications that have not been sent yet are dropped: counted in
/status, logged once with a bounded line, and left to the chain. A send already in flight is allowed to finish, but it does not start another retry/backoff step. - The cycle in progress gets
SHUTDOWN_TIMEOUT_MS(default10000,0skips the wait) to finish and write its cursors. - Any cursor state still only in memory is flushed to
CURSOR_FILE, then the health endpoint and the Telegram long-poll are closed and the process exits0.
Cursors only ever advance after their events have been handed to Telegram, so flushing at any point is safe: the file a restart resumes from never skips an event the chain still has to show. What the drain gives up is delivery of the messages it had not started — notifications are lossy by design and the chain is the record, exactly as for a failed Telegram send.
Why drop rather than finish the burst? Finishing means up to
MAX_NOTIFICATIONS_PER_CYCLE messages × 1.5s spacing plus retry backoff —
minutes that would hold a deploy open. Worse, hitting the deadline halfway
would leave the cursor behind messages that were already sent, replaying them
on restart. Dropping keeps the drain bounded and the resume exact.
A second SIGINT/SIGTERM exits immediately (130/143) if a drain ever
gets stuck. That skips the flush but never corrupts the file: the cursor is
written to a temporary file and renamed, so the worst case is resuming from the
last completed cycle. The teardown after the flush is capped too —
SHUTDOWN_TIMEOUT_MS + 10000 ms, then the process exits 1 with the cursor
file already written.
Where the drain is visible:
| Where | Field |
|---|---|
GET /health |
poller.stopping, poller.pendingFlush, poller.lastFlushAt, poller.notificationsDropped. A deliberate drain reports ok, not degraded |
/status |
stopping in the headline, dropped during shutdown N in the counters, and a drain line while it lasts |
Configuration is additive: SHUTDOWN_TIMEOUT_MS is optional (see
.env.example), no existing variable is renamed, and the version-1 cursor
format is unchanged — a deployment that omits the new key gets the 10000 ms
default.
The notifier is meant to run for weeks through Stellar RPC and Telegram outages. Everything it keeps in memory is fixed-size or capped:
- Per-target state is two small records (cursor, last event ledger, last error).
- The chain clock is one timestamp (the newest observed close time) plus its derived skew; it never accumulates history.
- A scan walks at most 20 event pages, and each cycle sends at most
MAX_NOTIFICATIONS_PER_CYCLEmessages; the rest are counted as skipped. - Error text is redacted (bot token) and clipped before it reaches
/status,/health, or logs; unknown or malformed events are logged as one bounded line. - At most one poll timer is pending, and
stop()leaves none behind.
tests/soak.test.mjs enforces this offline: it drives about 1,700 poll cycles
through a scripted fake RPC (outages, stale-cursor rejections, malformed and
unknown events) with every Telegram send failing, under mocked timers. It asserts
that heap growth after a forced GC stays under 4 MB, that status and every log
line stay bounded and token-free, and that timers do not accumulate. A control
test deliberately leaks per send and must trip the same threshold, so the check
cannot silently stop working. It needs no Testnet, Telegram credentials, or keys.
Deployment assumptions: one process per chat and cursor file (two writers
would race on CURSOR_FILE), the cursor path on persistent storage, and a
supervisor that restarts the process and probes GET /health. If you suspect a
leak in production, watch the process RSS over days; a restart is always safe.
Rollback: deploy the previous build and start it against the same
CURSOR_FILE. The cursor format is unchanged (version 1): chainClockAt is an
optional additive field that older builds ignore and newer builds load as null
when it is absent, and the chain is the source of truth, so nothing is replayed
beyond the last saved cursor and nothing needs migrating. Keep a copy of the
cursor file if you want an exact resume point.
The process exposes a loopback HTTP probe for supervisors and deploy
checks (default http://127.0.0.1:8787):
| Path | Meaning |
|---|---|
GET /health (alias /healthz) |
Readiness-style status. 200 when the poller is running and healthy, including an intentional operator pause; 503 when stopped or degraded (repeated RPC failures or a stale success window). The response includes poller.paused. |
GET /health/live (alias /livez) |
Liveness only — the process and HTTP server are up. Always 200 while listening. |
The JSON body is operational status only: poller counters, ledgers, truncated
cursors, whether a target has an error, and the chain clock (poller.chainClockAt
plus poller.chainClockSkewMs, the signed difference in milliseconds between the
bot's clock and the newest chain close time it has observed — positive while the
bot is ahead). It never includes BOT_TOKEN, chat ids, private keys, or
unbounded remote payloads.
Configuration (see .env.example):
HEALTH_HOST— bind address (default127.0.0.1; set to0.0.0.0for Docker)HEALTH_PORT— TCP port (default8787;0disables)HEALTH_STALE_MS— degraded if no successful poll within this window after the first success (default90000;0disables)
Rollback: set HEALTH_PORT=0 (or omit the new env keys to keep defaults) and
redeploy the previous image — the health module is additive and does not change
cursor format or Telegram behaviour.
Failure modes: binding fails only if the port is already taken (process exits via the listen error path after logging). Client disconnects and probe errors are logged and ignored so they cannot stop the notifier.
src/
index.ts entry point: config -> RPC -> bot -> poller -> health HTTP
mock-run.ts dry run: in-process mock RPC + real poller, log-only sends
health.ts local loopback GET /health for supervisors
config.ts env loading and validation, fails fast (MIMIR_PROFILE profiles)
bot.ts grammy setup: /start, /help, /status, /contracts, operator pause/resume
poller.ts the loop: scan, notify, persist the cursor
status.ts machine-readable status snapshot (allowlisted, bounded)
stellar/
client.ts Soroban RPC client + explorer links (tx + contract)
events.ts cursor-paginated getEvents (+ the standalone CLI)
decode.ts typed decoding of both contracts' events
mock-rpc.ts local Soroban mock: scenario, pagination, failure injection
mock-constants.ts mock profile fixture ids, ports, placeholder credentials
notifications/
format.ts decoded event -> MarkdownV2 message
The repo ships railway.json — Railway's Config-as-Code — that wires the
deployment to the rest of this repo:
| Setting | Value | Why |
|---|---|---|
build.buildCommand |
npm run build |
dist/ is gitignored; the image compiles it. |
deploy.startCommand |
npm start |
Run the built poller. |
deploy.healthcheckPath |
/health |
The same endpoint the local health module serves (GET /health). |
deploy.requiredMountPath |
/app/data |
Refuse to start unless a volume is attached where the cursor lives. |
deploy.restartPolicyType |
ON_FAILURE |
Restart on crash, bounded retries. |
deploy.numReplicas |
1 |
One poller owns the cursor; Railway volumes cannot be used with replicas. |
Volume is the one manual step — Railway never creates one from config:
railway volume add --mount-path /app/data # or attach it from the dashboardSet as Railway variables (secrets): BOT_TOKEN, TELEGRAM_CHAT_ID, and
HEALTH_HOST=0.0.0.0. Everything else keeps its repo default:
CURSOR_FILE=./data/cursor.json resolves to /app/data/cursor.json in
Railway's /app working directory, and the health endpoint binds the PORT
that Railway injects (the HEALTH_PORT fallback, see .env.example). No public
domain is needed — healthchecks run from Railway's probe host on the container
network, which is why HEALTH_HOST must not stay loopback-only here.
On deployed failure:
- A crash restarts under
ON_FAILURE; the volume keeps the cursor so there is no notification replay. A stale or corrupt cursor is already handled as a cold start, never a crash — see Failure behaviour. - A redeploy of a volume-backed service has a short downtime window (Railway keeps only one active deployment per volume); roll back to the previous revision and the cursor is still there.
Config-as-Code is deprecated by Railway in favour of Infrastructure as Code
(.railway/railway.ts with the Railway CLI), with a hard cutoff of 2026-12-01.
This file captures the current, working behaviour and is the migration source of
truth for IaC; follow Railway's migration guide when the time comes.
Run npm run typecheck for a no-emit TypeScript check, npm test for the build plus the deterministic command, poller, format, fixture, health and lockfile suites, or npm run build to produce the production output. CI runs typecheck, build, and all tests without network credentials.
package-lock.json is the install of record: deployments rebuild with npm ci,
so the committed lockfile must stay in sync with package.json and pin exactly
what it claims. Two checks enforce that, and CI runs both after npm ci:
npm run lockfile:check— offline. The lockfile islockfileVersion3, its root entry matchespackage.json's dependency ranges exactly, every package resolves to aregistry.npmjs.orgtarball with asha512integrity hash, and every direct dependency is pinned at the top level. Drift is reported by package name instead of being silently re-resolved.npm run lockfile:reproduce— asks npm to regenerate the lockfile from itself in a scratch directory and fails if the resolved package set changes, so a hand-edited or partially-resolved lockfile cannot land. The repository working tree is never written to.
The offline suite runs as part of npm test (tests/lockfile.test.mjs), so
drift is caught locally without network access. To change dependencies, edit
package.json, run npm install to regenerate the lockfile, and commit both
files together — a lockfile that no longer matches package.json fails
npm ci, npm run lockfile:check, and CI.
Run npm run typecheck for a no-emit TypeScript check, npm test for the build plus the deterministic command, poller, ledger-window, format, fixture, mock-profile and health suites (npm run test:mock for just the local-mock suites), or npm run build to produce the production output. CI runs typecheck, build, and all tests without network credentials.
Contributor workflow for credential-free fixtures (event catalogs, cursor samples, failure-mode expectations) lives in docs/contributor-fixtures.md. Automated tests never require live Testnet RPC access, Telegram credentials, or signing keys.
AGPL-3.0-or-later, matching the rest of Mimir.