Set up Discord session self-service: report and close¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Some sessions are narratorless — self-conducted, without a Narrator to record them (a player's own game, say). Filing one used to mean a message a Radny hand-fixed for syntax and pasted into the lore repository. This guide wires a Discord front-end that turns such a submission into a reviewable merge request in repozytorium-fabularne — with no daemon running anywhere.
This page is the one-time operator setup. Once it is live, the person-facing flow (the two Discord commands, the fields, what to expect back) is Submit a session report.
The write credential a private repository needs cannot live at the edge, so it lives in two
scale-to-zero places instead: a Cloudflare Worker at conjure.nerthus.pl (which holds only
a low-value pipeline trigger token) and GitLab CI (which holds the repo-write credential and
runs the module to render the session). Neither is a process you babysit.
flowchart LR
D["Discord<br/>/zgłoś · Zgłoś sesję"]
subgraph edge["Cloudflare edge"]
W["Worker<br/>conjure.nerthus.pl"]
end
subgraph gl["GitLab (Free) — scale-to-zero"]
P["Pipeline trigger"]
C["CI runner:<br/>ephemeral daemon renders<br/>POST /sessions/report"]
M["Merge request"]
end
R["Rada reviews + merges"]
D -->|"Ed25519-signed interaction"| W
W -->|"trigger token"| P
P --> C
C -->|"push -o merge_request.create"| M
M --> R
This diagram shows the fallback path. The Worker tries the live daemon first for every
interaction: it is offered to the fleet's primary, which (when awake and writable) enacts it
in-process and posts the followup itself, marked with ⚡. Only when the daemon cannot take it —
asleep, read-only, stale, or simply slow — does the interaction fall through to the trigger path
shown above. A report the daemon accepts ends in exactly the same place as the CI one, a
zgloszenia/<stamp> merge request the Rada reviews, except the daemon pushes the branch itself;
a submission the strict boundary rejects is answered without touching git at all, which is the
cheapest path of the four. A close that falls through pushes straight to main with no
merge request — see Closing from Discord. The trigger leg therefore
remains fully functional as the automatic fallback. Nothing exercises it: there is no
scheduled canary and there never was, so the first report that falls through is the first
test the road has had.
Fleet owns the switch's design.
The daemon declines a report rather than racing: past three seconds from the edge's send stamp it hands it back, because two enactments of one report would be two merge requests. It also declines outright when its own worktree holds an unpublished change, since the publish ends by resetting the serving branch to origin. Either decline is a CI minute, never a lost report.
The reporter never touches raw Markdown: the module renders canonical @-tags from the
command's fields, so a merged report reads like any other session and the Rada stops fixing syntax.
Why this shape¶
- No daemon. The module already runs headless in CI (Set up pipelines). The report job boots an ephemeral daemon inside the runner, renders the draft, and exits.
- No secret at the edge. The Worker holds only a pipeline trigger token (a leak lets someone fire pipelines — nothing more). The repo-write credential stays in GitLab CI.
- GitLab Free. Project access tokens are Premium-only. A deploy token cannot use the API and
cannot author a merge request. The credential is therefore a dedicated machine-user's personal
access token (
api+write_repository) — a real user, so themerge_request.*push options author the MR. The report driver opens that MR by design, and the Rada review is the gate; a close on the same token lands directly, guarded by the landing gate and the post-converge lint.
Two tools this guide assumes¶
- A Cloudflare Worker is a small JavaScript program that runs on Cloudflare's edge, with no server to keep up — it wakes on a request and scales to zero when idle. Ours verifies each Discord interaction and fires the GitLab trigger.
- Wrangler is Cloudflare's command-line tool for Workers (docs). You deploy the Worker and set its secrets with it — installed and authenticated in step 5.
Prerequisites¶
- The lore repo already builds pipelines on GitLab — Set up pipelines done.
nerthus.plon Cloudflare DNS with Workers enabled (free-tier). The domain's nameservers are delegated to Cloudflare, as for the daemon path (Publish the API).- Node.js on the machine you deploy from — wrangler installs and runs through npm. Command
registration no longer needs it: step 8 uses PowerShell (
vm/Register-NerthusDiscordCommands.ps1). - A Discord server you administer — to register the commands and link accounts.
1. Create the bot user¶
These three steps are human-only — GitLab has no API to register an account or mint a first token.
- Register the dedicated machine-user account (e.g.
nerthus-zgloszenia). The top-levelmargonemgroup is public, so the Free 5-seat private-namespace limit does not apply. - Invite it to
repozytorium-fabularneas Maintainer. The same machine-user PAT drives the pipelines and settings passes, which need Maintainer; the report push itself needs only Developer. - As the bot user, create a personal access token with scopes
api+write_repository. Copy it once; it becomesNERTHUS_PUSH_TOKEN.
main is unprotected, permanently — direct human and bot pushes are a first-class input, and
there is no protect-main stage anywhere in the system. What stops a bad report from landing is not
branch protection: the report driver opens a merge request by design, and a close is caught by
the semantic gate at landing, the post-converge lint, and the committed echoes
(Sync, Session model). Never call
Set-NerthusGitLabPipelines -ProtectMain.
2. Provision the CI with Set-NerthusGitLabPipelines¶
The whole GitLab side — removing GitLab's auto-created initial branch protection, the CI/CD
variables, the pipeline trigger token — is one idempotent script pass, not a dashboard crawl.
vm/Set-NerthusGitLabPipelines.ps1 is the primary path; Set up pipelines
owns its full flag reference, so here is only the report-specific call. -Token authenticates the
script (api scope) and -PushToken is stored as the CI/CD push credential; both can be the one
machine-user PAT from §1, which carries api + write_repository:
./vm/Set-NerthusGitLabPipelines.ps1 -Project margonem/nerthus/narratorzy/repozytorium-fabularne -Token <machine-user-PAT> `
-PushUser nerthus-zgloszenia -PushToken <machine-user-PAT> `
-TriggerToken -WhatIf # preview every call
./vm/Set-NerthusGitLabPipelines.ps1 -Project margonem/nerthus/narratorzy/repozytorium-fabularne -Token <machine-user-PAT> `
-PushUser nerthus-zgloszenia -PushToken <machine-user-PAT> `
-TriggerToken
Removing GitLab's initial protection is what the pass does by default; there is no switch that
asks for it. -SkipUnprotect is the opt-out, for a project whose main is protected on purpose.
What is report-specific about that call:
- Variables stay unprotected — do not pass
-Protected.mainis unprotected, so the report and close trigger pipelines run on an unprotected ref, where a Protected variable would arrive empty and the clone would fail. This matches the settle path (Set up pipelines). -PushUser+-PushTokensetNERTHUS_PUSH_USERunmasked andNERTHUS_PUSH_TOKENmasked — the real machine-user identitygit push -o merge_request.createneeds to author the MR. The username is not a secret, and GitLab rejects a masked value shorter than eight characters. The pass also removes GitLab's auto-created initial branch protection, so pushes work from day one — unconditionally, unless-SkipUnprotectsays otherwise;-TriggerTokenmints the Worker's token (§3).
Re-run it after a token rotation — it reconciles rather than duplicates. It cannot register the
bot account, mint the first PAT, or edit the committed .gitlab-ci.yml (§4) — those stay by hand.
By hand instead. Without a PowerShell host, set the same state under Settings → CI/CD →
Variables as unprotected variables: NERTHUS_MODULE_URL (the public clone URL, unmasked),
NERTHUS_PUSH_USER (unmasked), and NERTHUS_PUSH_TOKEN (masked). Remove any protection on main under
Settings → Repository → Protected branches, and mint the trigger token (§3).
3. Create the pipeline trigger token¶
The -TriggerToken switch in §2 ensures a conjure-worker trigger token and prints it — this
is the only secret the Worker holds. Set it as the Worker's GL_TRIGGER_TOKEN in step 7. By hand:
Settings → CI/CD → Pipeline trigger tokens → Add (description conjure-worker), then copy it.
4. Include the report template¶
Add to the lore repo's .gitlab-ci.yml. The workflow rule keeps a trigger pipeline to only the
single matching job — report or close (schedules and pushes are unaffected):
include:
- project: 'margonem/nerthus/it/nerthus.core'
file: 'ci/report.gitlab-ci.yml'
- project: 'margonem/nerthus/it/nerthus.core'
file: 'ci/close.gitlab-ci.yml' # /zamknij-sesję (see "Closing from Discord" below)
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "trigger" && $NERTHUS_REPORT_B64'
- if: '$CI_PIPELINE_SOURCE == "trigger" && $NERTHUS_CLOSE_DISCORD_ID'
- if: '$CI_PIPELINE_SOURCE != "trigger"'
The report job (ci/report.gitlab-ci.yml) decodes the trigger's NERTHUS_REPORT_B64, boots an
ephemeral daemon on the clone, calls POST /sessions/report, then pushes a zgloszenia/<stamp>
branch and opens the merge request through push options. Each trigger carries exactly one job's
variables, so a report trigger and a close trigger never collide.
5. Install and authenticate wrangler¶
From the module checkout, install wrangler and log wrangler into your Cloudflare account (install docs):
npm install -g wrangler # or: npm install --save-dev wrangler, then npx wrangler …
wrangler login # opens a browser to authorize; on a headless box use --browser=false
wrangler whoami # confirm it prints your account
wrangler login grants an OAuth token scoped to your account — no API key to paste. All
wrangler commands below read vm/report-worker.wrangler.toml via -c.
6. Create the Discord application¶
In the Discord Developer Portal create an application for the server:
- Copy its Application ID and Public Key (General Information). These are the Worker's
DISCORD_APP_IDandDISCORD_PUBLIC_KEY— the Worker verifies every interaction against the public key (Ed25519), so no bot token lives at the edge. - Under Bot, reset and copy the bot token. It is used once, for command registration in step 8 — never stored in the Worker.
7. Deploy the Worker at conjure.nerthus.pl¶
The Worker (vm/report-worker.js, config vm/report-worker.wrangler.toml) verifies the Discord
interaction signature, then fires the trigger.
wrangler deploy -c vm/report-worker.wrangler.toml
wrangler secret put GL_TRIGGER_TOKEN -c vm/report-worker.wrangler.toml # the token from step 3
Set the non-secret vars in report-worker.wrangler.toml (or the Cloudflare dashboard):
GL_PROJECT_PATH— the path ofrepozytorium-fabularne, namespace and all (margonem/nerthus/narratorzy/repozytorium-fabularne), not a numeric id. GitLab's/projects/:idendpoints take a URL-encoded path wherever they take an id, and the Worker does that encoding itself — so nothing on this path ever has to look an id up, and a project moved or renamed is a one-line var change rather than a hunt through the API.DISCORD_APP_ID,DISCORD_PUBLIC_KEY— from step 6.
Deploying against a test bed from .env
The [vars] above are committed production literals. To point the same Worker at a test
project without editing them, run vm/Publish-NerthusReportWorker.ps1: it overrides
GL_PROJECT_PATH / DISCORD_APP_ID / DISCORD_PUBLIC_KEY at deploy time from .env
(wrangler deploy --var), sets the GL_TRIGGER_TOKEN secret from
GITLAB_TRIGGER_TOKEN, and prints a drift line for each committed var against the
value it deploys. Set up a CI test repository drives it end to end.
The config declares conjure.nerthus.pl as a Worker Custom Domain (custom_domain = true),
so wrangler deploy creates the proxied DNS record and issues its certificate for you — you
do not add a DNS record by hand. (A plain route would instead require a pre-existing proxied
record; the Custom Domain is self-provisioning because the Worker owns the whole hostname.)
Rate-limit the endpoint¶
The interactions endpoint is public. The Worker's Ed25519 check is the real gate — a forged request never fires the trigger — so the rate limit's only job is to cap Worker invocations if someone floods the raw URL. Add one Cloudflare Rate limiting rule:
- In the Cloudflare dashboard, pick the
nerthus.plzone → Security → Security rules (older dashboards: Security → WAF → Rate limiting rules) → Create rule → Rate limiting rules. - If incoming requests match — Field URI Path, operator contains, value
/(every request to the Custom Domain hits this Worker, so the path match is just "all traffic"). - With the same characteristics — IP (the only counting characteristic on the Free plan).
- Rate — a generous ceiling, e.g. 50 requests / 10 s. Then Choose action → Block, duration the default.
- Deploy.
Two things this rule must not do, both of which would break the bot:
- Never use a Challenge action (Managed Challenge / JS Challenge). Discord delivers interactions server-to-server — there is no browser to solve a challenge, so every challenged request fails and the command times out. Use Block.
- Never set a tight per-IP threshold (the old
5 / 60 s). On the Free plan the counter keys on the source IP, and all of Discord's interaction traffic to you arrives from Discord's own shared IPs — a low ceiling would throttle the whole server at once. Keep it high; it is a flood backstop, not per-user throttling.
Business plan and up
Higher plans can match on request headers, so you can scope the rule to junk only:
block when the x-signature-ed25519 header is absent (every genuine Discord request
carries it). That lets you use a low threshold safely, because signed Discord traffic never
matches the rule. On Free, header matching is unavailable — stick with the generous
all-traffic Block above.
8. Point Discord at the Worker and register the commands¶
The Worker doubles as the Discord interactions endpoint — no gateway bot, no privileged intents, all serverless.
- Set the Interactions Endpoint URL to
https://conjure.nerthus.pl/(Developer Portal → your app → General Information). Discord verifies it by sending a signedPING, which the Worker answers with aPONG— the field must save green. (This is why the Worker must be deployed first.) - Add the application to your server. Guild-scoped registration only works if the app is
authorized in that guild — otherwise step 3 fails with
403 {"message":"Missing Access","code": 50001}. Open, as a user with Manage Server on the guild:https://discord.com/api/oauth2/authorize?client_id=<DISCORD_APP_ID>&scope=applications.commands%20bot&permissions=0applications.commandsis what authorizes the commands;bot(withpermissions=0— no extra powers) adds the app as a member. Pick your server and Authorize. Do this once per guild. - Register the commands once. Guild-scoped registration is instant; global takes up to
an hour to propagate, so start guild-scoped:
The registrar reads the command shapes from
pwsh vm/Register-NerthusDiscordCommands.ps1 -AppId <DISCORD_APP_ID> ` -Token $env:DISCORD_BOT_TOKEN -GuildId <your server id>vm/discord-commands.jsonand bulk-overwrites the whole set in one call.-Tokenis the bot token from step 6;-GuildIdis your server's id. The bot token is used for registration only — it is never stored in the Worker or logged.-AppId,-Token, and-GuildIddefault fromDISCORD_APP_ID/DISCORD_BOT_TOKEN/DISCORD_GUILD_IDin a.env(Set up credentials shows the file), so with those exported you can drop the arguments entirely. Add-WhatIfto preview without sending. Drop-GuildIdto register globally (~1 h). (Still getting50001? The app is not authorized in that guild, or-GuildIdpoints at a different one — redo step 2 for the right server.)
This registers four commands: the slash /zgłoś (opens a modal) and message "Zgłoś sesję"
report commands, plus the slash /zamknij-sesję (optional sesja hint) and message "Zamknij
sesję" close commands. All defer, fire the trigger, and let the CI job post the success-or-error followup —
the interaction token authorizes that followup for 15 minutes, so no bot token is needed at the
edge. Report MRs are labeled zgłoszenie/discord/<mode> for Rada triage. What each command does for
the operator is Submit a session report.
Closing from Discord (/zamknij-sesję)¶
The two close commands ride the same Worker and trigger token as the report commands — they are already registered by step 8 and already handled by the deployed Worker. Two things differ from a report:
- It closes for real and publishes to
main.ci/close.gitlab-ci.yml(included in §4) resolves the invoker's Discord id to a player, closes the session they own (or any session, for a Radny), and pushes the result straight to the default branch — a close is an enactment, not a reviewable draft. Provision the push credential the scripted way, as in §2:Set-NerthusGitLabPipelines -ClosePushToken <machine-user-pat>sets the maskedNERTHUS_CLOSE_PUSH_TOKEN. Left unset, it falls back toNERTHUS_SETTLE_PUSH_TOKEN— the same machine-user identity the settle and fleet paths use (Set up pipelines). (By hand instead: addNERTHUS_CLOSE_PUSH_TOKENas a masked CI/CD variable.) - Nothing special lets the close land —
mainis unprotected permanently. The push just works. What guardsmainis the close transaction, not a branch rule: the semantic gate re-runs against freshmainon every attempt, the post-converge lint reports any hand-edit drift, and the committed echoes dedup the enactment (Session model, Sync). Ownership is enforced in the daemon against the resolved player, so a linked account can only close its own sessions (a Radny, any).
The periodic reminder that nudges open sessions to their Narrator/Rada channels is a separate scheduled job — see Set up pipelines §4.
9. Attribution and who may report¶
This daemon-free path does not authenticate the invoker — the Worker verifies only that the interaction genuinely came from Discord (Ed25519), not who sent it. So anyone who can run the command in your server can file a report. Gate that in Discord itself: Server Settings → Integrations → [your app] restricts each command to chosen roles or channels. The MR review stays the real gate — nothing auto-lands.
There is no link store on this path. The Discord ⇄ Margonem map lives in the daemon's runtime
state, which the ephemeral CI runner does not have. A report is therefore attributed by the raw
discord:<id>, written into the MR description (od discord:<id>); a Radny matches that id to a
Gracz while reviewing. Automatic resolution — a report that lands already tied to a Gracz via
/auth/link — needs the live daemon (On a live daemon instead).
10. Test a run¶
Testing against a throwaway repo
Seed a disposable test bed with ci/New-NerthusCiTestRepo.ps1 rather than the live lore repo —
seeding, reseeding, and pointing the includes at an unmerged module branch are all owned by
Set up a CI test repository.
Report-path note: NERTHUS_MODULE_URL, NERTHUS_PUSH_USER, and NERTHUS_PUSH_TOKEN are set
unprotected (§2), matching the unprotected main the trigger pipelines run on. Keep them
unprotected on the test bed too — a Protected variable would arrive empty on the unprotected ref
and the clone would fail with The empty string is not a valid path.
- Run
/zgłośonce as an allowed member and submit the modal. Expect a deferred reply, then a followup confirming a merge request was opened for Rada review (Zgłoszenie przyjęte - utworzono MR do przeglądu Rady). - The pipeline's
nerthus-reportjob renders the draft and opens a merge request titledZgłoszenie sesji: <header>, source branchzgloszenia/<stamp>. - Open the MR: the diff is a single
zgłoszenia/<…>.mdin canonical form. A Radny finalizes it with the normal open/close workflow after merge. - Confirm the report driver opened a merge request rather than pushing
main— the report path is MR-by-design, independent of any branch rule.
On a live daemon instead¶
When a daemon is running (the fleet primary at evocation.nerthus.pl), the same endpoint serves
clients directly: a logged-in Gracz calls POST /sessions/report with their session token (the
session.report capability is in the gracz bundle). The write lands in zgłoszenia/, and the
daemon's own git sync publishes it — as a merge request when the host runs in mr publish mode
(Sync). The CI path in this guide is the daemon-free equivalent; both
converge on a reviewable merge request.
Security summary¶
- Two-tier secrets. The Worker holds only the trigger token. It fires only after the Ed25519 check passes, so the token cannot be spammed from outside; the rate limit just caps raw-flood invocations. The repo-write PAT never leaves GitLab CI.
- Report is MR-by-design. The report driver only ever opens a merge request, so a bad or spam
submission never auto-lands; the Rada review is the gate.
mainis unprotected, so a close lands directly — guarded by the landing gate, the post-converge lint, and the committed echoes, not by a branch rule. - Interactions are signed. The Worker verifies every Discord request against the app's Ed25519 public key and rejects anything else — the endpoint is public but not open.
- Canonical by construction. The ephemeral daemon renders the block, so the Rada reviews a syntactically clean session instead of repairing one.
- Attributed, not anonymous. Each report carries the reporter's raw
discord:<id>in the MR description (od discord:<id>, oranonimwhen Discord sends none); a Radny matches that id to aGraczwhile reviewing. There is no link store on this path — thegracz:<id>audit actor and automatic resolution belong to the live daemon only (On a live daemon instead). - Rotate the PAT. GitLab forces an expiry on user PATs — one calendar reminder to rotate
NERTHUS_PUSH_TOKEN.