Skip to content

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 the merge_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.pl on 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.

  1. Register the dedicated machine-user account (e.g. nerthus-zgloszenia). The top-level margonem group is public, so the Free 5-seat private-namespace limit does not apply.
  2. Invite it to repozytorium-fabularne as Maintainer. The same machine-user PAT drives the pipelines and settings passes, which need Maintainer; the report push itself needs only Developer.
  3. As the bot user, create a personal access token with scopes api + write_repository. Copy it once; it becomes NERTHUS_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. main is 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 + -PushToken set NERTHUS_PUSH_USER unmasked and NERTHUS_PUSH_TOKEN masked — the real machine-user identity git push -o merge_request.create needs 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 -SkipUnprotect says otherwise; -TriggerToken mints 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:

  1. Copy its Application ID and Public Key (General Information). These are the Worker's DISCORD_APP_ID and DISCORD_PUBLIC_KEY — the Worker verifies every interaction against the public key (Ed25519), so no bot token lives at the edge.
  2. 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 of repozytorium-fabularne, namespace and all (margonem/nerthus/narratorzy/repozytorium-fabularne), not a numeric id. GitLab's /projects/:id endpoints 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:

  1. In the Cloudflare dashboard, pick the nerthus.pl zone → Security → Security rules (older dashboards: Security → WAF → Rate limiting rules) → Create rule → Rate limiting rules.
  2. 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").
  3. With the same characteristicsIP (the only counting characteristic on the Free plan).
  4. Rate — a generous ceiling, e.g. 50 requests / 10 s. Then Choose action → Block, duration the default.
  5. 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.

  1. Set the Interactions Endpoint URL to https://conjure.nerthus.pl/ (Developer Portal → your app → General Information). Discord verifies it by sending a signed PING, which the Worker answers with a PONG — the field must save green. (This is why the Worker must be deployed first.)
  2. 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=0
    
    applications.commands is what authorizes the commands; bot (with permissions=0 — no extra powers) adds the app as a member. Pick your server and Authorize. Do this once per guild.
  3. Register the commands once. Guild-scoped registration is instant; global takes up to an hour to propagate, so start guild-scoped:
    pwsh vm/Register-NerthusDiscordCommands.ps1 -AppId <DISCORD_APP_ID> `
        -Token $env:DISCORD_BOT_TOKEN -GuildId <your server id>
    
    The registrar reads the command shapes from vm/discord-commands.json and bulk-overwrites the whole set in one call. -Token is the bot token from step 6; -GuildId is your server's id. The bot token is used for registration only — it is never stored in the Worker or logged. -AppId, -Token, and -GuildId default from DISCORD_APP_ID / DISCORD_BOT_TOKEN / DISCORD_GUILD_ID in a .env (Set up credentials shows the file), so with those exported you can drop the arguments entirely. Add -WhatIf to preview without sending. Drop -GuildId to register globally (~1 h). (Still getting 50001? The app is not authorized in that guild, or -GuildId points 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 masked NERTHUS_CLOSE_PUSH_TOKEN. Left unset, it falls back to NERTHUS_SETTLE_PUSH_TOKEN — the same machine-user identity the settle and fleet paths use (Set up pipelines). (By hand instead: add NERTHUS_CLOSE_PUSH_TOKEN as a masked CI/CD variable.)
  • Nothing special lets the close land — main is unprotected permanently. The push just works. What guards main is the close transaction, not a branch rule: the semantic gate re-runs against fresh main on 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.

  1. 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).
  2. The pipeline's nerthus-report job renders the draft and opens a merge request titled Zgłoszenie sesji: <header>, source branch zgloszenia/<stamp>.
  3. Open the MR: the diff is a single zgłoszenia/<…>.md in canonical form. A Radny finalizes it with the normal open/close workflow after merge.
  4. 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. main is 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>, or anonim when Discord sends none); a Radny matches that id to a Gracz while reviewing. There is no link store on this path — the gracz:<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.