From d55d3fcdfa38b499d24d97fe74404b6385d1b9f5 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 13:53:43 +0200 Subject: [PATCH 1/6] Add personal RevBank commands through a private gateway --- .env.example | 5 + CHANGELOG.md | 2 + docs/.vitepress/config.ts | 4 + docs/adr/0018-private-revbank-gateway.md | 32 ++++ docs/architecture.md | 16 ++ docs/identity-and-access.md | 5 + docs/maintainers/index.md | 1 + docs/privacy.md | 9 + docs/runbook.md | 19 +++ infra/envs/dev/main.tf | 1 + infra/envs/dev/variables.tf | 11 ++ infra/envs/prod/main.tf | 1 + infra/envs/prod/variables.tf | 11 ++ infra/modules/pixel/locals.tf | 2 + infra/modules/pixel/variables.tf | 11 ++ infra/secrets.manifest.yaml | 8 + src/app.test.ts | 33 ++++ src/app.ts | 4 + src/config.test.ts | 43 +++++ src/config.ts | 27 +++ src/core/ports/revbank.ts | 46 +++++ src/features/capabilities.ts | 2 + src/features/features.test.ts | 2 + src/features/index.ts | 4 + src/features/revbank/index.ts | 203 +++++++++++++++++++++++ src/features/revbank/revbank.test.ts | 163 ++++++++++++++++++ src/services/revbank.test.ts | 121 ++++++++++++++ src/services/revbank.ts | 117 +++++++++++++ 28 files changed, 903 insertions(+) create mode 100644 docs/adr/0018-private-revbank-gateway.md create mode 100644 src/core/ports/revbank.ts create mode 100644 src/features/revbank/index.ts create mode 100644 src/features/revbank/revbank.test.ts create mode 100644 src/services/revbank.test.ts create mode 100644 src/services/revbank.ts diff --git a/.env.example b/.env.example index e7cfe52..4adca42 100644 --- a/.env.example +++ b/.env.example @@ -97,3 +97,8 @@ SENTRY_DSN= # Time zone for /events, /schedule, and {{date}} tokens in user-set posts. Default Europe/Amsterdam. # PIXEL_TIMEZONE=Europe/Amsterdam # PIXEL_CONTENT_DIR=content + +# Private RevBank gateway. Both or neither; HTTPS (HTTP only for loopback tests). +# Do not point this at the backup repository. Pixel never reads that repository. +REVBANK_API_URL= +REVBANK_API_TOKEN= diff --git a/CHANGELOG.md b/CHANGELOG.md index a95af2f..48057c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ### Added +- Optional private RevBank gateway integration: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised link-management commands. Pixel never reads the backup or persists financial responses. + - CHANGELOG conflicts resolve themselves when that is safe. When `main`'s `CHANGELOG.md` changes (and after each Release), a new workflow merges `main` into every open PR from this repo that edits it. If `CHANGELOG.md` is the only conflict, it keeps both sides' `[Unreleased]` entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new `[Unreleased]` after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, `just changelog-merge` does the same in a stopped merge or rebase, and `just changelog-merge-driver` makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts"). ### Changed diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 324069a..1258ccf 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -171,6 +171,10 @@ export default defineConfig({ text: "0017 · One writer for app secrets", link: "/maintainers/adr/0017-one-writer-for-app-secrets", }, + { + text: "0018 · Private RevBank gateway", + link: "/maintainers/adr/0018-private-revbank-gateway", + }, ], }, ], diff --git a/docs/adr/0018-private-revbank-gateway.md b/docs/adr/0018-private-revbank-gateway.md new file mode 100644 index 0000000..2752089 --- /dev/null +++ b/docs/adr/0018-private-revbank-gateway.md @@ -0,0 +1,32 @@ +# 0018. Personal RevBank access through a private gateway + +Status: accepted for implementation; deployment requires operator configuration. + +## Context + +People want to read their own prepaid balance and bookings in Pixel. RevBank and its existing Git backup must remain unchanged. Pixel is public source; the backup contains private account records, terminal input and transaction descriptions. + +## Decision + +Pixel calls a separately deployed private gateway. Only that gateway can read a local mirror of the backup. Pixel has no Git credentials, backup mount, account catalogue, or persisted financial data. The gateway's code and operations live in a separate private repository. Public code contains only the typed API port, HTTP client, commands and fictional fixtures. + +- Personal commands are available to guests: a RevBank account does not grant Pixel membership, and membership does not grant access to a RevBank account. +- The authenticated Discord actor is the only identity used for personal requests. There is no personal `user` or `nick` selector on balance/history requests. Replies and failures are private from the initial deferral onward. +- A request carries a nickname, expires after 24 hours and receives a unique request ID. It reveals no account existence. A manager verifies ownership independently in person, then approves that exact request for the selected Discord user. Self-approval is refused by the gateway. +- Link management requires the `revbank-link-admin` capability at member tier in Pixel **and** a separately maintained immutable-ID allow-list in the gateway. Managers can inspect pending/link metadata and approve, reject or revoke; they cannot read another person's balance. Approvals enforce one Discord identity per account. Unlinking is available to the owner without a manager. +- The gateway reads fixed filenames from one immutable Git commit. It never checks out or executes backup content. Account creation markers bind links to account incarnations, so a missing or recreated account fails closed. Unrecognised formats and account/log inconsistencies fail closed. Historic data is taken from confirmed booking records, not commit messages or Git diffs. +- Only the last ten confirmed bookings are exposed. Labels come from recognised catalogue descriptions; other descriptions become generic credit/debit/adjustment labels. Raw terminal input, counterparties and free-form log content never cross the API. +- Pixel stores no financial response. HTTP requests have an eight-second deadline, a streamed 16 KiB response cap, strict schemas, no redirects and no automatic retries. Errors contain fixed codes only. There is no financial/request-body logging. +- The gateway is on the private network, reached through HTTPS and a dedicated bearer token. HTTP is allowed only for loopback testing. No public ingress or Tailscale Funnel. Dev must use a separate gateway, token and fictional backup. + +## Consequences and limits + +The gateway trusts Pixel to identify the authenticated Discord actor correctly. A stolen Pixel service token can impersonate linked users; a private repository does not remove that trust. Token rotation and network ACLs remain necessary. Neither Pixel operators nor gateway operators are cryptographically prevented from accessing the data they administer. + +Data is as recent as the backup commit, not live. The private service enforces a configured maximum age and reports the commit time; old commits may mean either a quiet space or an interrupted backup. No financial writes, payments, reminders, public statistics or automated membership changes are included. + +RevBank's terminal operates on trust. A nickname entered there is not proof of ownership. Renames require unlinking and a fresh, independently verified request. Restore procedures must not silently resurrect revoked links. The private service owns the link-state audit and retention; the Pixel privacy notice describes the integration separately from ordinary Pixel storage. + +## Validation + +Tests cover dispatcher gating, private replies, actor-only requests, schema/size failures, missing or expired approvals, request replacement, duplicate links, self-approval, revocation, recreated accounts, inconsistent snapshots and removal of private descriptions. Operators must review the gateway, configure access and verify its private deployment before enabling production. diff --git a/docs/architecture.md b/docs/architecture.md index 4fb45f1..900af65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -439,6 +439,8 @@ Environment variables are validated by `config.ts` (zod). Nothing else reads `pr | `PIXEL_CLOSING_TIME_FILE` | | Optional. Operator-authored closing-time body. Default `{PIXEL_DATA_DIR}/closing-time.md`. Missing or empty → built-in default. Not a secret | | `DISCORD_ROLE_MEMBER` | | Optional. A Discord role name or ID that the `member` level is mirrored to. Unset means not mirrored | | `DISCORD_ROLE_FRIEND` | | Optional. A Discord role name or ID that the `friend` level is mirrored to. Unset means not mirrored | +| `REVBANK_API_URL` | | Optional HTTPS origin of the private RevBank gateway; both URL and token or neither | +| `REVBANK_API_TOKEN` | | Secret bearer token for the private RevBank gateway; never backup Git credentials | | `HOME_ASSISTANT_URL` | | Optional, with the token. Where Pixel reaches Home Assistant (http or https). On Azure prefer the Tailscale sidecar address; a Nabu Casa URL still works | | `HOME_ASSISTANT_TOKEN` | | Optional, with the URL. A long-lived access token from a **non-admin** Home Assistant user. A secret | | `MASTODON_INSTANCE` | | Optional, with the token. Origin of the Mastodon instance (http or https). A path is stripped so only the origin is used. Never hard-coded | @@ -582,4 +584,18 @@ Record significant decisions as short ADRs in `docs/adr/NNNN-title.md`. Numbers | 0014 | Release registers prod slash commands; `pixel-github-prod` may read only `discord-token` | accepted | | 0015 | Secrets manifest (`infra/secrets.manifest.yaml`) is the one list of Key Vault secrets; Terraform reads it and a test fails on drift | accepted | | 0016 | `just secrets-pull` / `just secrets-push` sync the manifest's app secrets between Key Vault and `.env.`, diffing against the live vault before a push; `just pull` / `just push` add the shared `terraform-tfvars` and read-only share mirrors | accepted | +| 0018 | Personal RevBank reads through a private gateway with independently approved account links | accepted | | 0017 | `just secrets-push` / `just push` are the only writers of the app secrets; Terraform's `write_secrets` writes only the infra group, and `removed` blocks drop the old app secrets from state without deleting them | accepted | + + +## Personal RevBank access + +The optional RevBank integration reads personal balances and the last ten bookings through a separately hosted private gateway. Pixel never reads the Git backup itself. The gateway owns link requests, approvals, immutable Discord-ID bindings and financial-data filtering. All `/revbank` replies are private. Guests may request their own link; managing links requires the `revbank-link-admin` capability and independent authorisation in the gateway. No financial writes or local cache. See [ADR 0018](adr/0018-private-revbank-gateway.md) and the [runbook](runbook.md#revbank). + +```mermaid +flowchart LR + BACKUP[Private existing Git backup] -->|read-only mirror| GATEWAY[Private RevBank gateway] + PIXEL[Pixel] -->|HTTPS, service token, caller Discord ID| GATEWAY + GATEWAY -->|only caller's balance or bookings| PIXEL + PIXEL -->|private reply| DISCORD[Discord caller] +``` diff --git a/docs/identity-and-access.md b/docs/identity-and-access.md index 7652fd2..c8ba01e 100644 --- a/docs/identity-and-access.md +++ b/docs/identity-and-access.md @@ -232,3 +232,8 @@ These are recorded so that phase 1 doesn't make them harder. Each will get an AD 4. Both accounts are notified. `/unlink` from either side. Guests can't link (no entry to add to). Admins' Telegram IDs are added by hand in both files. - ~~**Database-backed grants**~~: done without a database (#25; #18 closed). Admin commands rewrite `members.yaml`. - **Freshness:** once tiers come from outside sources, re-verify a tier older than 24 hours before privileged commands, and reconcile every night. + + +## RevBank links + +RevBank links are separate from membership and platform-account linking. The private gateway, not `members.yaml`, stores the approved mapping from an immutable Discord ID to a RevBank account. `/revbank` personal commands are guest-accessible but the gateway returns financial data only after independent ownership verification. `/revbank-manage` requires member tier plus `revbank-link-admin`; the gateway separately checks its manager ID allow-list. Link managers have no financial-read override. See [ADR 0018](adr/0018-private-revbank-gateway.md). diff --git a/docs/maintainers/index.md b/docs/maintainers/index.md index 30c55b1..5523289 100644 --- a/docs/maintainers/index.md +++ b/docs/maintainers/index.md @@ -39,6 +39,7 @@ Architecture decision records, newest last. | [0015](./adr/0015-secrets-manifest) | One manifest lists every Key Vault secret. | | [0016](./adr/0016-secrets-sync) | `just pull` / `just push` (and `secrets-pull` / `secrets-push`) sync tfvars and app secrets between Key Vault and the laptop. | | [0017](./adr/0017-one-writer-for-app-secrets) | Only the sync tool writes app secrets; Terraform writes just the infra group. | +| [0018](./adr/0018-private-revbank-gateway) | Personal RevBank reads through a separate private gateway; verified account links. | ## Working on the site diff --git a/docs/privacy.md b/docs/privacy.md index c6d27d1..5533c39 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -51,3 +51,12 @@ Deletion removes you from Pixel's access lists and scheduled posts. Logs and Sen - [Identity and access](identity-and-access.md) (why Pixel stores IDs, and how authorisation works) - [Operations runbook](runbook.md#privacy-requests) (how operators handle a request) + + +## Optional RevBank integration + +When configured, `/revbank` sends your Discord ID and requested action to a private RevBank gateway. A link request also sends the nickname you enter. The gateway stores pending requests (up to 24 hours), approved account links until unlinked/revoked, and a bounded management audit; its operator controls backup retention. Link managers verify ownership before approving a specific request. They can inspect link metadata but cannot use Pixel to read another person's financial history. + +Pixel displays your balance or last ten confirmed bookings in a private Discord reply and does not persist the financial response, nickname, or request body in its logs or Sentry. Standard command names, outcomes and caller identity are logged as described above. Discord receives the private reply and handles it under its own policy. The gateway removes raw terminal input, counterparties and free-form booking descriptions; catalogue labels may be shown. + +`/revbank unlink` removes the current link and pending request from the gateway. It does not delete RevBank's original financial records, old state backups, management audit entries or Discord's copy of replies. Contact the board for those requests. This optional integration needs the same operator/board review of retention as the rest of this notice. diff --git a/docs/runbook.md b/docs/runbook.md index 9c8ca0b..626017c 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -672,3 +672,22 @@ If `members.yaml` is lost and there's no backup, Pixel won't start (it fails clo - **When Terraform lands:** search for `🚧` and replace each one with real steps, keeping the "Today" steps only where they still apply. Add the real environment names, links and the on-call contact to "Who and what". - **Every procedure should be followed once by someone other than its author, in dev,** before it's trusted. Fix whatever they trip over, and note the date here if you like. - **After an incident,** add what you learned: a missing step, a better check, a new entry under [Incidents](#incidents). + + +## RevBank + +RevBank is optional and off unless both `REVBANK_API_URL` and `REVBANK_API_TOKEN` are set. The URL must be an HTTPS origin; loopback HTTP is allowed for local tests only. The token lives in Key Vault as `revbank-api-token` (app group). Set `revbank_api_url` in the environment's Terraform settings and push the app secret through `just secrets-push dev` before enabling it. Follow the existing reviewed production process for prod. Never put backup credentials or a backup mount in Pixel. + +Deploy the gateway from its separate private repository, following its runbook. It reads a local mirror of the existing backup and must not modify RevBank or its backup remote. Use a private network endpoint, HTTPS, explicit network ACLs and separate tokens/state for dev and prod. Dev uses fictional financial data only. This change does not deploy the gateway, configure Tailscale ACLs, grant capabilities or alter live secrets. + +After deployment, register commands with `just register dev` (production uses the Release workflow). Give a verified link manager the `revbank-link-admin` capability with `/admin capabilities grant` and add their immutable Discord ID to the gateway's separate manager allow-list. Both checks must pass; an admin tier alone grants no link-management permission. + +1. The owner uses `/revbank link nick:` and contacts a link manager in person. +2. The manager verifies the account belongs to that person, uses `/revbank-manage review user:`, and checks the nickname and request ID. +3. The manager uses `/revbank-manage approve user: request:` with that exact ID. Requests expire after 24 hours; an edited/replaced request needs a fresh review. Managers cannot approve themselves. +4. The owner checks `/revbank status`, `/revbank balance` and `/revbank transactions`. Replies are private, sourced from the backup, and include its timestamp. +5. `/revbank unlink` removes the owner's link and pending request. A manager can use `/revbank-manage reject user: request:` or `/revbank-manage revoke user:`. These commands do not change financial records. + +On unavailable data, check the gateway's backup freshness, parser compatibility and per-account reconciliation. Do not send raw logs or balances to Pixel logs, Sentry or public issues. If the API returns a conflict, review the request again; do not retry approvals automatically. Gateway denial may mean its independent allow-list is missing the manager. A renamed or recreated account requires a fresh verified link; never silently migrate it by nickname. + +Rotate the service token on both sides if compromised, restrict the network path and inspect the gateway's management audit. A gateway token authenticates Pixel, which asserts caller IDs; a compromised Pixel can impersonate linked callers. Unlinking removes access, not RevBank's original bookkeeping or Discord's copy of a response. Follow the private service's retention and restore instructions so revoked links are not resurrected. diff --git a/infra/envs/dev/main.tf b/infra/envs/dev/main.tf index d9fa5c5..d3d611f 100644 --- a/infra/envs/dev/main.tf +++ b/infra/envs/dev/main.tf @@ -18,6 +18,7 @@ module "pixel" { discord_role_friend = var.discord_role_friend home_assistant_url = var.home_assistant_url mastodon_instance = var.mastodon_instance + revbank_api_url = var.revbank_api_url spacestate_api_url = var.spacestate_api_url spacestate_additional_headers_enabled = var.spacestate_additional_headers_enabled sentry_enabled = var.sentry_enabled diff --git a/infra/envs/dev/variables.tf b/infra/envs/dev/variables.tf index 711b803..f303734 100644 --- a/infra/envs/dev/variables.tf +++ b/infra/envs/dev/variables.tf @@ -158,3 +158,14 @@ variable "tailscale_image" { type = string default = "ghcr.io/tailscale/tailscale:v1.86.5" } + +variable "revbank_api_url" { + type = string + default = null + description = "Optional HTTPS origin for the private RevBank gateway. REVBANK_API_TOKEN is supplied through Key Vault; no backup credentials are given to Pixel." + + validation { + condition = var.revbank_api_url == null || can(regex("^https://[^/?#@]+/?$", var.revbank_api_url)) + error_message = "revbank_api_url must be an HTTPS origin or unset." + } +} diff --git a/infra/envs/prod/main.tf b/infra/envs/prod/main.tf index 185f008..5fc96b7 100644 --- a/infra/envs/prod/main.tf +++ b/infra/envs/prod/main.tf @@ -18,6 +18,7 @@ module "pixel" { discord_role_friend = var.discord_role_friend home_assistant_url = var.home_assistant_url mastodon_instance = var.mastodon_instance + revbank_api_url = var.revbank_api_url spacestate_api_url = var.spacestate_api_url spacestate_additional_headers_enabled = var.spacestate_additional_headers_enabled sentry_enabled = var.sentry_enabled diff --git a/infra/envs/prod/variables.tf b/infra/envs/prod/variables.tf index ef3d056..5510fe0 100644 --- a/infra/envs/prod/variables.tf +++ b/infra/envs/prod/variables.tf @@ -164,3 +164,14 @@ variable "github_register_principal_id" { default = null description = "Principal ID of pixel-github-prod (az identity show -g pixel-bootstrap -n pixel-github-prod --query principalId). Lets the Release workflow read discord-token, and only that, to register slash commands (ADR 0014)." } + +variable "revbank_api_url" { + type = string + default = null + description = "Optional HTTPS origin for the private RevBank gateway. REVBANK_API_TOKEN is supplied through Key Vault; no backup credentials are given to Pixel." + + validation { + condition = var.revbank_api_url == null || can(regex("^https://[^/?#@]+/?$", var.revbank_api_url)) + error_message = "revbank_api_url must be an HTTPS origin or unset." + } +} diff --git a/infra/modules/pixel/locals.tf b/infra/modules/pixel/locals.tf index 9c15d88..9ea5880 100644 --- a/infra/modules/pixel/locals.tf +++ b/infra/modules/pixel/locals.tf @@ -27,6 +27,7 @@ locals { mastodon = var.mastodon_instance != null spacestate = var.spacestate_api_url != null spacestate-headers = var.spacestate_api_url != null && var.spacestate_additional_headers_enabled + revbank = var.revbank_api_url != null sentry = var.sentry_enabled tailscale = var.tailscale_enabled container-registry = var.container_registry_server != null @@ -69,6 +70,7 @@ locals { var.discord_announce_bot_channel_id != null ? { DISCORD_ANNOUNCE_BOT_CHANNEL_ID = var.discord_announce_bot_channel_id } : {}, var.discord_role_member != null ? { DISCORD_ROLE_MEMBER = var.discord_role_member } : {}, var.discord_role_friend != null ? { DISCORD_ROLE_FRIEND = var.discord_role_friend } : {}, + var.revbank_api_url != null ? { REVBANK_API_URL = var.revbank_api_url } : {}, var.home_assistant_url != null ? { HOME_ASSISTANT_URL = var.home_assistant_url } : {}, var.mastodon_instance != null ? { MASTODON_INSTANCE = var.mastodon_instance } : {}, var.spacestate_api_url != null ? { SPACESTATE_API_URL = var.spacestate_api_url } : {}, diff --git a/infra/modules/pixel/variables.tf b/infra/modules/pixel/variables.tf index 1134387..a26b5d3 100644 --- a/infra/modules/pixel/variables.tf +++ b/infra/modules/pixel/variables.tf @@ -234,3 +234,14 @@ variable "purge_protection_enabled" { default = false description = "Key Vault purge protection. Leave off on dev so the vault can be torn down; turn on for prod (#12)." } + +variable "revbank_api_url" { + type = string + default = null + description = "Optional HTTPS origin for the private RevBank gateway. REVBANK_API_TOKEN is supplied through Key Vault; no backup credentials are given to Pixel." + + validation { + condition = var.revbank_api_url == null || can(regex("^https://[^/?#@]+/?$", var.revbank_api_url)) + error_message = "revbank_api_url must be an HTTPS origin or unset." + } +} diff --git a/infra/secrets.manifest.yaml b/infra/secrets.manifest.yaml index 94e2451..c896313 100644 --- a/infra/secrets.manifest.yaml +++ b/infra/secrets.manifest.yaml @@ -22,6 +22,7 @@ features: mastodon: Mastodon announcements (MASTODON_INSTANCE is set) spacestate: /spacestate (SPACESTATE_API_URL is set) spacestate-headers: Extra headers for the /spacestate call (spacestate_additional_headers_enabled) + revbank: Private RevBank gateway (REVBANK_API_URL is set) sentry: Sentry error reporting (sentry_enabled) tailscale: Tailscale sidecar (tailscale_enabled) container-registry: Private image registry (container_registry_server is set) @@ -87,6 +88,13 @@ secrets: feature: spacestate-headers description: Extra headers for the space state API, as a JSON object (for example the address the call is made from). + - vault: revbank-api-token + group: app + delivery: env + env: REVBANK_API_TOKEN + feature: revbank + description: Authentication token for the private RevBank gateway. + - vault: sentry-dsn group: app delivery: env diff --git a/src/app.test.ts b/src/app.test.ts index 22b93a2..e445e5b 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -8,6 +8,7 @@ import { CapabilityError } from "./core/capabilities.ts"; import { MESSAGES } from "./core/dispatcher.ts"; import { silentLogger } from "./core/logger.ts"; import { nullErrorReporter } from "./core/ports/error-reporter.ts"; +import { REVBANK_LINK_ADMIN } from "./features/revbank/index.ts"; import { SCHEDULE_CAPABILITY } from "./features/schedules/index.ts"; import { SPACESTATE_CAPABILITY } from "./features/spacestate/index.ts"; import { actor, IDS } from "./testing/fixtures.ts"; @@ -70,6 +71,36 @@ describe("buildCore", () => { afterEach(() => rmSync(dir, { recursive: true, force: true })); + it("wires private RevBank reads and gates link management independently of admin tier", async () => { + config.revbank = { url: "https://gateway.example", token: "x".repeat(32) }; + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ balanceCents: 850, updatedAt: "2026-10-21T10:00:00Z" })); + const { dispatcher } = buildCore(config, silentLogger, nullErrorReporter, { fetch }); + const balance = await dispatcher.dispatch({ + actor: actor({ userId: IDS.guest }), + command: "revbank", + subcommand: "balance", + args: {}, + }); + expect(balance.private).toBe(true); + expect(balance.reply.text).toContain("€8.50"); + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({ + actor: `discord:${IDS.guest}`, + }); + for (const userId of [IDS.guest, IDS.member, IDS.admin]) { + const denied = await dispatcher.dispatch({ + actor: actor({ userId }), + command: "revbank-manage", + subcommand: "review", + args: { user: IDS.friend }, + users: { user: { id: IDS.friend, displayName: "Example", isBot: false } }, + }); + expect(denied.reply.text).toBe(MESSAGES.deniedTier); + } + expect(fetch).toHaveBeenCalledTimes(1); + }); + it.each([ [IDS.admin, true], [IDS.member, false], @@ -352,6 +383,7 @@ describe("buildCore", () => { const DOOR = [ { name: "door", description: "Open the door" }, SCHEDULE_CAPABILITY, + REVBANK_LINK_ADMIN, SPACESTATE_CAPABILITY, ]; const person = { id: IDS.member, displayName: "Grace", handle: "grace", isBot: false }; @@ -370,6 +402,7 @@ describe("buildCore", () => { "ha-switches", "ha-doors", "schedule-posts", + "revbank-link-admin", "spacestate", ]); const listed = await core.dispatcher.dispatch({ diff --git a/src/app.ts b/src/app.ts index 3d16147..72a1ba4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -33,6 +33,7 @@ import { AnnouncementRouteStore } from "./services/announcement-routes.ts"; import { HomeDeviceStore } from "./services/home-devices.ts"; import { infoVariables, loadInfoTopics } from "./services/info-content.ts"; import { KindSwitch } from "./services/kind-switch.ts"; +import { HttpRevbank } from "./services/revbank.ts"; import { ScheduleStore } from "./services/schedules.ts"; import { HttpSpaceStateApi } from "./services/space-state-api.ts"; import { FileSpaceStateStore } from "./services/space-state-store.ts"; @@ -184,6 +185,9 @@ export function buildCore( reporter, spaceStatus, spaceState, + revbank: config.revbank + ? new HttpRevbank({ ...config.revbank, ...(options.fetch ? { fetch: options.fetch } : {}) }) + : undefined, announcer, calendar, channelPosts, diff --git a/src/config.test.ts b/src/config.test.ts index f1fdb1e..57c41c5 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -583,3 +583,46 @@ describe("the bot status channel and build info", () => { expect(() => loadConfig({ ...VALID, PIXEL_GIT_SHA: "not a sha" })).toThrow(/PIXEL_GIT_SHA/); }); }); + +describe("RevBank configuration", () => { + it("is off by default and requires URL and token together", () => { + expect(loadConfig(VALID).revbank).toBeUndefined(); + for (const partial of [ + { REVBANK_API_URL: "https://gateway.example" }, + { REVBANK_API_TOKEN: "x".repeat(32) }, + ]) { + expect(() => loadConfig({ ...VALID, ...partial })).toThrow(/REVBANK_API_URL/); + } + }); + it("only permits HTTPS origins or loopback HTTP, without credentials or a query", () => { + const token = "x".repeat(32); + for (const url of [ + "https://gateway.example", + "http://127.0.0.1:8787", + "http://localhost:8787", + "http://[::1]:8787", + ]) { + expect( + loadConfig({ ...VALID, REVBANK_API_URL: url, REVBANK_API_TOKEN: token }).revbank?.url, + ).toBe(url); + } + for (const url of [ + "http://gateway.example", + "https://user:password@gateway.example", + "https://gateway.example/data", + "https://gateway.example?token=secret", + "https://gateway.example#fragment", + ]) { + expect(() => + loadConfig({ ...VALID, REVBANK_API_URL: url, REVBANK_API_TOKEN: token }), + ).toThrow(/REVBANK_API_URL/); + } + expect(() => + loadConfig({ + ...VALID, + REVBANK_API_URL: "https://gateway.example", + REVBANK_API_TOKEN: "short", + }), + ).toThrow(/REVBANK_API_TOKEN/); + }); +}); diff --git a/src/config.ts b/src/config.ts index d5661e7..fdab200 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,6 +50,21 @@ const extraHeaders = z.string().transform((raw, ctx) => { return Object.fromEntries(entries.map(([name, value]) => [name, String(value).trim()])); }); +const revbankUrl = z.url({ protocol: /^https?$/ }).refine( + (value) => { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.search && + !url.hash && + url.pathname === "/" && + (url.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) + ); + }, + { error: "must be an HTTPS origin (HTTP is allowed only on loopback)" }, +); + const pixelEnv = z.enum(["local", "dev", "prod"]).default("local"); const pixelRuntime = z.enum(["local", "cloud"]).default("local"); @@ -94,6 +109,9 @@ const envSchema = z // Optional, and only used when the three above are set. SPACESTATE_ADDITIONAL_HEADERS: optional(extraHeaders), + REVBANK_API_URL: optional(revbankUrl), + REVBANK_API_TOKEN: optional(z.string().trim().min(32)), + DISCORD_TOKEN: z.string().min(1), DISCORD_APP_ID: snowflake, DISCORD_GUILD_ID: snowflake, @@ -131,6 +149,10 @@ const envSchema = z .filter((part) => part.length > 0); }, z.array(z.enum(ANNOUNCEMENT_KINDS)).optional()), }) + .refine((e) => (e.REVBANK_API_URL === undefined) === (e.REVBANK_API_TOKEN === undefined), { + path: ["REVBANK_API_URL"], + error: "set both REVBANK_API_URL and REVBANK_API_TOKEN, or neither", + }) .refine((e) => (e.HOME_ASSISTANT_URL === undefined) === (e.HOME_ASSISTANT_TOKEN === undefined), { path: ["HOME_ASSISTANT_URL"], error: "set both HOME_ASSISTANT_URL and HOME_ASSISTANT_TOKEN, or neither", @@ -179,6 +201,7 @@ export type Config = { /** How many of those traces get a CPU profile (0–1). Default 1. Unused without a DSN. */ sentryProfileSessionSampleRate: number; spaceApiUrl: string; + revbank?: { url: string; token: string }; /** * The API `/spacestate` calls to set the space open or closed: its address, and the * user and token it is called with. The user and token come from Key Vault when hosted @@ -271,6 +294,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { sentryTracesSampleRate: e.SENTRY_TRACES_SAMPLE_RATE ?? 1, sentryProfileSessionSampleRate: e.SENTRY_PROFILE_SESSION_SAMPLE_RATE ?? 1, spaceApiUrl: e.SPACEAPI_URL, + revbank: + e.REVBANK_API_URL && e.REVBANK_API_TOKEN + ? { url: e.REVBANK_API_URL, token: e.REVBANK_API_TOKEN } + : undefined, spaceStateApi: e.SPACESTATE_API_URL !== undefined && e.SPACESTATE_USER !== undefined && diff --git a/src/core/ports/revbank.ts b/src/core/ports/revbank.ts new file mode 100644 index 0000000..c104cfc --- /dev/null +++ b/src/core/ports/revbank.ts @@ -0,0 +1,46 @@ +/** Personal RevBank data. The private service owns links and authorisation. */ +export type RevbankLink = { + state: "unlinked" | "pending" | "linked"; + requestId?: string; + nick?: string; + expiresAt?: string; +}; + +export type RevbankBalance = { + balanceCents: number; + updatedAt: string; +}; + +export type RevbankTransaction = { + id: string; + at: string; + amountCents: number; + balanceCents: number; + /** Public catalogue description or a generic label; never raw log text. */ + description: string; +}; + +export type RevbankHistory = { + updatedAt: string; + transactions: RevbankTransaction[]; +}; + +export type Revbank = { + status(actor: string): Promise; + request(actor: string, nick: string): Promise; + unlink(actor: string): Promise; + balance(actor: string): Promise; + history(actor: string): Promise; + review(actor: string, target: string): Promise; + decide(actor: string, target: string, requestId: string, approve: boolean): Promise; + revoke(actor: string, target: string): Promise; +}; + +export type RevbankErrorCode = "unavailable" | "not-linked" | "denied" | "conflict"; + +/** No upstream body, address, credentials, nickname or financial data in errors. */ +export class RevbankError extends Error { + constructor(readonly code: RevbankErrorCode) { + super(`RevBank: ${code}`); + } +} diff --git a/src/features/capabilities.ts b/src/features/capabilities.ts index 62d7f4b..72d7c7a 100644 --- a/src/features/capabilities.ts +++ b/src/features/capabilities.ts @@ -1,6 +1,7 @@ import type { CapabilityDefinition } from "../core/capabilities.ts"; import { homeCapabilities } from "../core/home-access.ts"; import { HOME_KINDS } from "../core/home-kinds/index.ts"; +import { REVBANK_LINK_ADMIN } from "./revbank/index.ts"; import { SCHEDULE_CAPABILITY } from "./schedules/index.ts"; import { SPACESTATE_CAPABILITY } from "./spacestate/index.ts"; @@ -18,5 +19,6 @@ import { SPACESTATE_CAPABILITY } from "./spacestate/index.ts"; export const CAPABILITIES: readonly CapabilityDefinition[] = [ ...homeCapabilities(HOME_KINDS), SCHEDULE_CAPABILITY, + REVBANK_LINK_ADMIN, SPACESTATE_CAPABILITY, ]; diff --git a/src/features/features.test.ts b/src/features/features.test.ts index 01d04a5..42092e0 100644 --- a/src/features/features.test.ts +++ b/src/features/features.test.ts @@ -99,6 +99,8 @@ describe("buildFeatures", () => { "help", "info", "ping", + "revbank", + "revbank-manage", "schedule", "spacestate", "status", diff --git a/src/features/index.ts b/src/features/index.ts index 326df18..c912df8 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -10,6 +10,7 @@ import type { Logger } from "../core/logger.ts"; import type { AccessStore } from "../core/ports/access-store.ts"; import type { ErrorReporter } from "../core/ports/error-reporter.ts"; import type { FeedbackSink } from "../core/ports/feedback.ts"; +import type { Revbank } from "../core/ports/revbank.ts"; import type { RoleMirror } from "../core/role-mirror.ts"; import type { HomeDeviceStore } from "../services/home-devices.ts"; import type { InfoTopic } from "../services/info-content.ts"; @@ -25,6 +26,7 @@ import { createHelpFeature } from "./help/index.ts"; import { createHomeFeature } from "./home/index.ts"; import { createInfoFeature } from "./info/index.ts"; import { createPingFeature } from "./ping/index.ts"; +import { createRevbankFeature } from "./revbank/index.ts"; import { createSchedulesFeature } from "./schedules/index.ts"; import { createSpaceStateFeature } from "./spacestate/index.ts"; import { createStatusFeature } from "./status/index.ts"; @@ -32,6 +34,7 @@ import { createWhoamiFeature } from "./whoami/index.ts"; export type FeatureDeps = { version: string; + revbank?: Revbank; runtime: "local" | "cloud"; startedAt: Date; access: AccessStore; @@ -88,6 +91,7 @@ export function buildFeatures(deps: FeatureDeps): Feature[] { createEventsFeature({ calendar: deps.calendar, timezone: deps.timezone }), createInfoFeature({ topics: deps.infoTopics }), createWhoamiFeature(), + createRevbankFeature({ api: deps.revbank, timezone: deps.timezone }), createFeedbackFeature({ sink: deps.feedback }), createSchedulesFeature({ store: deps.schedules, diff --git a/src/features/revbank/index.ts b/src/features/revbank/index.ts new file mode 100644 index 0000000..554304a --- /dev/null +++ b/src/features/revbank/index.ts @@ -0,0 +1,203 @@ +import { actorRef } from "../../core/access.ts"; +import type { CommandContext, SubcommandDefinition } from "../../core/command.ts"; +import { UserFacingError } from "../../core/errors.ts"; +import type { Feature } from "../../core/feature.ts"; +import { escapeMarkdown } from "../../core/format.ts"; +import { type Revbank, RevbankError, type RevbankLink } from "../../core/ports/revbank.ts"; +import type { Reply } from "../../core/reply.ts"; + +export const REVBANK_LINK_ADMIN = { + name: "revbank-link-admin", + description: "Review, approve and revoke RevBank account links (no access to balances)", +}; +const MESSAGES = { + unavailable: "RevBank is unavailable or its backup is too old. Please try again later.", + "not-linked": + "You do not have a usable RevBank link. Use `/revbank status`; unlink an old account before requesting a new link.", + denied: "The RevBank service refused this action.", + conflict: "The link or request has changed, expired, or cannot be approved. Review it again.", +}; +const money = (cents: number) => + new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }).format(cents / 100); +const when = (value: string, timezone: string) => + new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", + timeStyle: "short", + timeZone: timezone, + }).format(new Date(value)); + +function describe(link: RevbankLink): string { + if (link.state === "unlinked") return "No RevBank account is linked."; + if (link.state === "linked") return `Linked to **${escapeMarkdown(link.nick ?? "")}**.`; + return `Pending request for **${escapeMarkdown(link.nick ?? "")}**.\nRequest: ${link.requestId}\nA link manager must verify that this account is yours before approving it.`; +} + +export function createRevbankFeature(deps: { + api: Revbank | undefined; + timezone: string; +}): Feature { + const run = + (action: (api: Revbank, ctx: CommandContext) => Promise) => + async (ctx: CommandContext): Promise => { + if (!deps.api) return { text: "RevBank isn't set up here.", private: true }; + try { + return { ...(await action(deps.api, ctx)), private: true }; + } catch (error) { + if (!(error instanceof RevbankError)) throw error; + return { text: MESSAGES[error.code], private: true }; + } + }; + const own = ( + name: string, + description: string, + action: (api: Revbank, ctx: CommandContext) => Promise, + ): SubcommandDefinition => ({ + name, + description, + access: { minTier: "guest" }, + private: true, + handler: run(action), + }); + const target = (ctx: CommandContext) => { + const user = ctx.users.user; + if (!user || user.isBot) throw new UserFacingError("Choose a person."); + return `discord:${user.id}`; + }; + const requestId = (ctx: CommandContext) => { + const id = ctx.args.request; + if ( + typeof id !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id) + ) + throw new UserFacingError("Use the request ID from the review."); + return id; + }; + const userOption = { + name: "user", + description: "The person whose link to manage", + type: "user", + required: true, + } as const; + const manager = ( + name: string, + description: string, + action: (api: Revbank, ctx: CommandContext) => Promise, + decision = false, + ): SubcommandDefinition => ({ + name, + description, + access: { minTier: "member", capability: REVBANK_LINK_ADMIN.name }, + private: true, + options: [ + userOption, + ...(decision + ? [ + { + name: "request", + description: "The exact request ID you reviewed", + type: "string" as const, + required: true, + }, + ] + : []), + ], + handler: run(action), + }); + return { + name: "revbank", + commands: [ + { + name: "revbank", + description: "Your private RevBank account", + access: { minTier: "guest" }, + subcommands: [ + own("status", "Show your RevBank link or pending request", async (api, ctx) => ({ + text: describe(await api.status(actorRef(ctx.principal))), + })), + { + ...own("link", "Request a verified link to your RevBank account", async (api, ctx) => { + const nick = ctx.args.nick; + if (typeof nick !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(nick)) + throw new UserFacingError( + "Enter a RevBank account name using letters, numbers, dots, hyphens or underscores.", + ); + return { text: describe(await api.request(actorRef(ctx.principal), nick)) }; + }), + options: [ + { + name: "nick", + description: "Your RevBank account name", + type: "string", + required: true, + }, + ], + }, + own("unlink", "Remove your link and any pending request", async (api, ctx) => { + await api.unlink(actorRef(ctx.principal)); + return { text: "Your RevBank link and pending request have been removed." }; + }), + own("balance", "Show your balance from the latest usable backup", async (api, ctx) => { + const result = await api.balance(actorRef(ctx.principal)); + return { + text: `Your RevBank balance: **${money(result.balanceCents)}**\nBackup: ${when(result.updatedAt, deps.timezone)}. Purchases since then may not be included.`, + }; + }), + own("transactions", "Show your last ten confirmed bookings", async (api, ctx) => { + const result = await api.history(actorRef(ctx.principal)); + return { + text: `Backup: ${when(result.updatedAt, deps.timezone)}.`, + embeds: [ + { + title: "Your latest RevBank bookings", + ...(result.transactions.length === 0 + ? { description: "No confirmed bookings found." } + : {}), + fields: result.transactions.map((tx) => ({ + name: `${when(tx.at, deps.timezone)} · ${money(tx.amountCents)}`, + value: `${escapeMarkdown(tx.description)} · balance ${money(tx.balanceCents)}`, + })), + }, + ], + }; + }), + ], + }, + { + name: "revbank-manage", + description: "Manage verified RevBank links, without viewing balances", + access: { minTier: "member", capability: REVBANK_LINK_ADMIN.name }, + subcommands: [ + manager( + "review", + "Review a person's link request before verifying ownership", + async (api, ctx) => ({ + text: describe(await api.review(actorRef(ctx.principal), target(ctx))), + }), + ), + manager( + "approve", + "Approve the exact request after verifying ownership in person", + async (api, ctx) => { + await api.decide(actorRef(ctx.principal), target(ctx), requestId(ctx), true); + return { text: "RevBank link approved." }; + }, + true, + ), + manager( + "reject", + "Reject the exact link request", + async (api, ctx) => { + await api.decide(actorRef(ctx.principal), target(ctx), requestId(ctx), false); + return { text: "RevBank request rejected." }; + }, + true, + ), + manager("revoke", "Remove a person's link and pending request", async (api, ctx) => { + await api.revoke(actorRef(ctx.principal), target(ctx)); + return { text: "RevBank link and pending request revoked." }; + }), + ], + }, + ], + }; +} diff --git a/src/features/revbank/revbank.test.ts b/src/features/revbank/revbank.test.ts new file mode 100644 index 0000000..caa47b2 --- /dev/null +++ b/src/features/revbank/revbank.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import { isGroup, isSubgroup } from "../../core/command.ts"; +import { type Revbank, RevbankError } from "../../core/ports/revbank.ts"; +import { context, IDS, principal } from "../../testing/fixtures.ts"; +import { createRevbankFeature, REVBANK_LINK_ADMIN } from "./index.ts"; + +const request = "10000000-0000-4000-8000-000000000001"; +const api = () => ({ + status: vi.fn().mockResolvedValue({ state: "unlinked" }), + request: vi.fn().mockResolvedValue({ state: "pending", nick: "Example", requestId: request }), + unlink: vi.fn().mockResolvedValue(undefined), + balance: vi.fn().mockResolvedValue({ balanceCents: 850, updatedAt: "2026-10-21T10:00:00Z" }), + history: vi.fn().mockResolvedValue({ + updatedAt: "2026-10-21T10:00:00Z", + transactions: [ + { + id: "2", + at: "2026-10-21T09:00:00Z", + amountCents: -150, + balanceCents: 850, + description: "Cola *test*", + }, + ], + }), + review: vi.fn().mockResolvedValue({ state: "linked", nick: "Example" }), + decide: vi.fn().mockResolvedValue(undefined), + revoke: vi.fn().mockResolvedValue(undefined), +}); +function setup(client: Revbank | undefined = api()) { + const feature = createRevbankFeature({ api: client, timezone: "UTC" }); + const command = (name: string, management = false) => { + const group = feature.commands?.[management ? 1 : 0]; + if (!group || !isGroup(group)) throw new Error("Missing group"); + const command = group.subcommands.find((c) => c.name === name); + if (!command || isSubgroup(command)) throw new Error("Missing command"); + return command; + }; + return { feature, command }; +} +const ctx = () => + context({ + principal: principal("member", { userId: IDS.member }), + users: { user: { id: IDS.friend, displayName: "Untrusted name", isBot: false } }, + }); + +describe("RevBank commands", () => { + it("marks every command private and gates managers with an explicit capability", () => { + const { feature } = setup(); + for (const group of feature.commands ?? []) { + if (!isGroup(group)) throw new Error("Expected group"); + for (const command of group.subcommands) { + if (isSubgroup(command)) throw new Error("Expected subcommand"); + expect(command.private).toBe(true); + } + } + expect(feature.commands?.[1]?.access).toEqual({ + minTier: "member", + capability: REVBANK_LINK_ADMIN.name, + }); + }); + it("shows unlinked, pending and linked status without interpreting nicknames", async () => { + const client = api(); + const { command } = setup(client); + expect((await command("status").handler(ctx())).text).toContain("No RevBank"); + client.status.mockResolvedValueOnce({ state: "pending", nick: "test*", requestId: request }); + expect((await command("status").handler(ctx())).text).toContain(request); + client.status.mockResolvedValueOnce({ state: "linked", nick: "test*" }); + expect((await command("status").handler(ctx())).text).toContain("test\\*"); + client.status.mockResolvedValueOnce({ state: "linked" }); + await command("status").handler(ctx()); + client.status.mockResolvedValueOnce({ state: "pending" }); + await command("status").handler(ctx()); + }); + it("uses the caller's immutable ID for linking, balances, history and unlink", async () => { + const client = api(); + const { command } = setup(client); + await command("link").handler({ ...ctx(), args: { nick: "Example" } }); + expect(client.request).toHaveBeenCalledWith(`discord:${IDS.member}`, "Example"); + expect((await command("balance").handler(ctx())).text).toContain("€8.50"); + expect( + (await command("transactions").handler(ctx())).embeds?.[0]?.fields?.[0]?.value, + ).toContain("Cola \\*test\\*"); + client.history.mockResolvedValueOnce({ updatedAt: "2026-10-21T10:00:00Z", transactions: [] }); + expect((await command("transactions").handler(ctx())).embeds?.[0]?.description).toContain( + "No confirmed", + ); + await command("unlink").handler(ctx()); + for (const mock of [client.balance, client.history, client.unlink]) + expect(mock).toHaveBeenCalledWith(`discord:${IDS.member}`); + }); + it.each([undefined, "", "-cash", "../file", "someone else", "x".repeat(65), 42])( + "refuses invalid nick %s before calling the service", + async (nick) => { + const client = api(); + const { command } = setup(client); + await expect(command("link").handler({ ...ctx(), args: { nick } })).rejects.toThrow( + "Enter a RevBank", + ); + expect(client.request).not.toHaveBeenCalled(); + }, + ); + it("manages the chosen ID and exact request, without reading financial data", async () => { + const client = api(); + const { command } = setup(client); + await command("review", true).handler(ctx()); + await command("approve", true).handler({ ...ctx(), args: { request } }); + await command("reject", true).handler({ ...ctx(), args: { request } }); + await command("revoke", true).handler(ctx()); + expect(client.review).toHaveBeenCalledWith(`discord:${IDS.member}`, `discord:${IDS.friend}`); + expect(client.decide).toHaveBeenCalledWith( + `discord:${IDS.member}`, + `discord:${IDS.friend}`, + request, + true, + ); + expect(client.decide).toHaveBeenCalledWith( + `discord:${IDS.member}`, + `discord:${IDS.friend}`, + request, + false, + ); + expect(client.revoke).toHaveBeenCalledWith(`discord:${IDS.member}`, `discord:${IDS.friend}`); + expect(client.balance).not.toHaveBeenCalled(); + }); + it("requires a person and a valid request ID", async () => { + const { command } = setup(); + await expect(command("review", true).handler(context())).rejects.toThrow("Choose a person"); + await expect( + command("review", true).handler({ + ...ctx(), + users: { user: { id: IDS.friend, displayName: "bot", isBot: true } }, + }), + ).rejects.toThrow("Choose a person"); + for (const request of [undefined, "bad", 3]) + await expect( + command("approve", true).handler({ ...ctx(), args: { request } }), + ).rejects.toThrow("request ID"); + }); + it.each(["unavailable", "not-linked", "denied", "conflict"] as const)( + "returns private, safe %s errors", + async (code) => { + const client = api(); + client.balance.mockRejectedValueOnce(new RevbankError(code)); + const reply = await setup(client).command("balance").handler(ctx()); + expect(reply.private).toBe(true); + expect(reply.text).toBeTruthy(); + }, + ); + it("propagates unexpected programming errors and handles disabled config", async () => { + const client = api(); + client.balance.mockRejectedValueOnce(new Error("bug")); + await expect(setup(client).command("balance").handler(ctx())).rejects.toThrow("bug"); + const feature = createRevbankFeature({ api: undefined, timezone: "UTC" }); + const group = feature.commands?.[0]; + if (!group || !isGroup(group)) throw new Error("group"); + const command = group.subcommands[0]; + if (!command || isSubgroup(command)) throw new Error("command"); + expect(await command.handler(ctx())).toEqual({ + text: "RevBank isn't set up here.", + private: true, + }); + }); +}); diff --git a/src/services/revbank.test.ts b/src/services/revbank.test.ts new file mode 100644 index 0000000..e7ae211 --- /dev/null +++ b/src/services/revbank.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { HttpRevbank } from "./revbank.ts"; + +const actor = "discord:100000000000000001"; +const target = "discord:100000000000000002"; +const token = "fake-token-for-testing-only-not-secret"; +const requestId = "10000000-0000-4000-8000-000000000001"; +const at = "2026-10-21T10:00:00+00:00"; + +function setup(body: unknown = {}) { + const fetch = vi.fn().mockResolvedValue(Response.json(body)); + return { fetch, api: new HttpRevbank({ url: "https://gateway.example/", token, fetch }) }; +} + +describe("private RevBank client", () => { + it("sends only the authenticated actor and operation, without redirects", async () => { + const { api, fetch } = setup({ balanceCents: 1234, updatedAt: at }); + expect(await api.balance(actor)).toEqual({ balanceCents: 1234, updatedAt: at }); + const call = fetch.mock.calls[0]; + if (!call) throw new Error("No request"); + const [url, init] = call; + expect(String(url)).toBe("https://gateway.example/v1/balance"); + expect(init).toMatchObject({ + method: "POST", + redirect: "error", + body: JSON.stringify({ actor }), + headers: { authorization: `Bearer ${token}` }, + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + it("implements the link lifecycle and manager actions", async () => { + const { api, fetch } = setup({ state: "unlinked" }); + expect(await api.status(actor)).toEqual({ state: "unlinked" }); + fetch.mockResolvedValueOnce( + Response.json({ state: "pending", nick: "Example", requestId, expiresAt: at }), + ); + expect(await api.request(actor, "Example")).toMatchObject({ requestId }); + fetch.mockResolvedValueOnce(Response.json({ state: "linked", nick: "Example" })); + expect((await api.review(actor, target)).state).toBe("linked"); + fetch.mockImplementation(async () => Response.json({})); + await api.unlink(actor); + await api.decide(actor, target, requestId, true); + await api.decide(actor, target, requestId, false); + await api.revoke(actor, target); + expect( + fetch.mock.calls + .slice(-3) + .map(([url, init]) => [String(url), JSON.parse(String(init?.body))]), + ).toEqual([ + ["https://gateway.example/v1/approve", { actor, target, requestId }], + ["https://gateway.example/v1/reject", { actor, target, requestId }], + ["https://gateway.example/v1/revoke", { actor, target }], + ]); + }); + it("validates history and rejects unexpected fields or unsafe numbers", async () => { + const body = { + updatedAt: at, + transactions: [{ id: "1", at, amountCents: -150, balanceCents: 850, description: "Cola" }], + }; + const { api, fetch } = setup(body); + expect(await api.history(actor)).toEqual(body); + for (const bad of [ + { ...body, accounts: [] }, + { updatedAt: "yesterday", transactions: [] }, + { updatedAt: at, transactions: Array(11).fill(body.transactions[0]) }, + ]) { + fetch.mockResolvedValueOnce(Response.json(bad)); + await expect(api.history(actor)).rejects.toMatchObject({ code: "unavailable" }); + } + fetch.mockResolvedValueOnce( + Response.json({ balanceCents: Number.MAX_SAFE_INTEGER + 1, updatedAt: at }), + ); + await expect(api.balance(actor)).rejects.toMatchObject({ code: "unavailable" }); + }); + it.each([ + [403, "denied"], + [404, "not-linked"], + [409, "conflict"], + [503, "unavailable"], + ])("maps HTTP %s without retaining the body", async (status, code) => { + const { api, fetch } = setup(); + fetch.mockResolvedValueOnce( + new Response("private upstream details", { status: Number(status) }), + ); + await expect(api.balance(actor)).rejects.toMatchObject({ code }); + fetch.mockResolvedValueOnce(new Response(null, { status: Number(status) })); + await expect(api.balance(actor)).rejects.toMatchObject({ code }); + }); + it("discards network errors, invalid JSON and missing bodies", async () => { + const { api, fetch } = setup(); + fetch.mockRejectedValueOnce(new Error(`private URL and ${token}`)); + await expect(api.balance(actor)).rejects.toThrow("RevBank: unavailable"); + fetch.mockResolvedValueOnce(new Response("not json")); + await expect(api.balance(actor)).rejects.toThrow("RevBank: unavailable"); + fetch.mockResolvedValueOnce(new Response(null)); + await expect(api.balance(actor)).rejects.toThrow("RevBank: unavailable"); + }); + it("bounds chunked response bodies and cancels the stream", async () => { + const { api, fetch } = setup(); + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(16_385)); + }, + cancel, + }); + fetch.mockResolvedValueOnce(new Response(stream)); + await expect(api.history(actor)).rejects.toMatchObject({ code: "unavailable" }); + expect(cancel).toHaveBeenCalledOnce(); + }); + it("uses the default fetch when not injected", async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ state: "unlinked" })); + vi.stubGlobal("fetch", fetch); + try { + await new HttpRevbank({ url: "https://gateway.example/", token }).status(actor); + } finally { + vi.unstubAllGlobals(); + } + expect(fetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/services/revbank.ts b/src/services/revbank.ts new file mode 100644 index 0000000..b6d1a6b --- /dev/null +++ b/src/services/revbank.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; +import { type Revbank, RevbankError } from "../core/ports/revbank.ts"; + +const cents = z.number().int().safe(); +const instant = z.iso.datetime({ offset: true }); +const link = z.discriminatedUnion("state", [ + z.object({ state: z.literal("unlinked") }).strict(), + z.object({ state: z.literal("linked"), nick: z.string().min(1).max(64) }).strict(), + z + .object({ + state: z.literal("pending"), + nick: z.string().min(1).max(64), + requestId: z.uuid(), + expiresAt: instant, + }) + .strict(), +]); +const balance = z.object({ balanceCents: cents, updatedAt: instant }).strict(); +const history = z + .object({ + updatedAt: instant, + transactions: z + .array( + z + .object({ + id: z.string().regex(/^\d{1,20}$/), + at: instant, + amountCents: cents, + balanceCents: cents, + description: z.string().min(1).max(200), + }) + .strict(), + ) + .max(10), + }) + .strict(); +const empty = z.object({}).strict(); + +/** All requests are bounded, private POSTs. No redirects, retries or response logging. */ +export class HttpRevbank implements Revbank { + readonly #fetch: typeof globalThis.fetch; + constructor( + private readonly options: { url: string; token: string; fetch?: typeof globalThis.fetch }, + ) { + this.#fetch = options.fetch ?? globalThis.fetch; + } + status(actor: string) { + return this.#call("status", { actor }, link); + } + request(actor: string, nick: string) { + return this.#call("link", { actor, nick }, link); + } + async unlink(actor: string) { + await this.#call("unlink", { actor }, empty); + } + balance(actor: string) { + return this.#call("balance", { actor }, balance); + } + history(actor: string) { + return this.#call("transactions", { actor }, history); + } + review(actor: string, target: string) { + return this.#call("review", { actor, target }, link); + } + async decide(actor: string, target: string, requestId: string, approve: boolean) { + await this.#call(approve ? "approve" : "reject", { actor, target, requestId }, empty); + } + async revoke(actor: string, target: string) { + await this.#call("revoke", { actor, target }, empty); + } + + async #call(operation: string, body: object, schema: z.ZodType): Promise { + try { + const response = await this.#fetch(new URL(`/v1/${operation}`, this.options.url), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(8_000), + headers: { + authorization: `Bearer ${this.options.token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new RevbankError( + response.status === 403 + ? "denied" + : response.status === 404 + ? "not-linked" + : response.status === 409 + ? "conflict" + : "unavailable", + ); + } + if (!response.body) throw new RevbankError("unavailable"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 16_384) throw new RevbankError("unavailable"); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return schema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch (error) { + if (error instanceof RevbankError) throw error; + throw new RevbankError("unavailable"); + } + } +} From ea583a5fab44bb95348330f9b761857210fe29ca Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 14:03:26 +0200 Subject: [PATCH 2/6] Group RevBank management commands and simplify account copy --- CHANGELOG.md | 2 +- docs/identity-and-access.md | 2 +- docs/runbook.md | 8 +-- src/app.test.ts | 30 ++++++++++- src/features/features.test.ts | 1 - src/features/revbank/index.ts | 74 ++++++++++++++-------------- src/features/revbank/revbank.test.ts | 22 ++++++--- 7 files changed, 87 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48057c8..aea0a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ### Added -- Optional private RevBank gateway integration: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised link-management commands. Pixel never reads the backup or persists financial responses. +- Optional private RevBank gateway integration: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. Pixel never reads the backup or persists financial responses. - CHANGELOG conflicts resolve themselves when that is safe. When `main`'s `CHANGELOG.md` changes (and after each Release), a new workflow merges `main` into every open PR from this repo that edits it. If `CHANGELOG.md` is the only conflict, it keeps both sides' `[Unreleased]` entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new `[Unreleased]` after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, `just changelog-merge` does the same in a stopped merge or rebase, and `just changelog-merge-driver` makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts"). diff --git a/docs/identity-and-access.md b/docs/identity-and-access.md index c8ba01e..808bb07 100644 --- a/docs/identity-and-access.md +++ b/docs/identity-and-access.md @@ -236,4 +236,4 @@ These are recorded so that phase 1 doesn't make them harder. Each will get an AD ## RevBank links -RevBank links are separate from membership and platform-account linking. The private gateway, not `members.yaml`, stores the approved mapping from an immutable Discord ID to a RevBank account. `/revbank` personal commands are guest-accessible but the gateway returns financial data only after independent ownership verification. `/revbank-manage` requires member tier plus `revbank-link-admin`; the gateway separately checks its manager ID allow-list. Link managers have no financial-read override. See [ADR 0018](adr/0018-private-revbank-gateway.md). +RevBank links are separate from membership and platform-account linking. The private gateway, not `members.yaml`, stores the approved mapping from an immutable Discord ID to a RevBank account. `/revbank` personal commands are guest-accessible but the gateway returns financial data only after independent ownership verification. `/revbank manage` requires member tier plus `revbank-link-admin`; the gateway separately checks its manager ID allow-list. Link managers have no financial-read override. See [ADR 0018](adr/0018-private-revbank-gateway.md). diff --git a/docs/runbook.md b/docs/runbook.md index 626017c..b0d6289 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -682,11 +682,11 @@ Deploy the gateway from its separate private repository, following its runbook. After deployment, register commands with `just register dev` (production uses the Release workflow). Give a verified link manager the `revbank-link-admin` capability with `/admin capabilities grant` and add their immutable Discord ID to the gateway's separate manager allow-list. Both checks must pass; an admin tier alone grants no link-management permission. -1. The owner uses `/revbank link nick:` and contacts a link manager in person. -2. The manager verifies the account belongs to that person, uses `/revbank-manage review user:`, and checks the nickname and request ID. -3. The manager uses `/revbank-manage approve user: request:` with that exact ID. Requests expire after 24 hours; an edited/replaced request needs a fresh review. Managers cannot approve themselves. +1. The owner selects `/revbank link`, fills in their RevBank nickname in Discord’s required **nick** field, and contacts a link manager in person. +2. The manager verifies the account belongs to that person, uses `/revbank manage review user:`, and checks the nickname and request ID. +3. The manager uses `/revbank manage approve user: request:` with that exact ID. Requests expire after 24 hours; an edited/replaced request needs a fresh review. Managers cannot approve themselves. 4. The owner checks `/revbank status`, `/revbank balance` and `/revbank transactions`. Replies are private, sourced from the backup, and include its timestamp. -5. `/revbank unlink` removes the owner's link and pending request. A manager can use `/revbank-manage reject user: request:` or `/revbank-manage revoke user:`. These commands do not change financial records. +5. `/revbank unlink` removes the owner's link and pending request. A manager can use `/revbank manage reject user: request:` or `/revbank manage revoke user:`. These commands do not change financial records. On unavailable data, check the gateway's backup freshness, parser compatibility and per-account reconciliation. Do not send raw logs or balances to Pixel logs, Sentry or public issues. If the API returns a conflict, review the request again; do not retry approvals automatically. Gateway denial may mean its independent allow-list is missing the manager. A renamed or recreated account requires a fresh verified link; never silently migrate it by nickname. diff --git a/src/app.test.ts b/src/app.test.ts index e445e5b..c28a9c0 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -76,7 +76,8 @@ describe("buildCore", () => { const fetch = vi .fn() .mockResolvedValue(Response.json({ balanceCents: 850, updatedAt: "2026-10-21T10:00:00Z" })); - const { dispatcher } = buildCore(config, silentLogger, nullErrorReporter, { fetch }); + const core = buildCore(config, silentLogger, nullErrorReporter, { fetch }); + const { dispatcher } = core; const balance = await dispatcher.dispatch({ actor: actor({ userId: IDS.guest }), command: "revbank", @@ -91,7 +92,8 @@ describe("buildCore", () => { for (const userId of [IDS.guest, IDS.member, IDS.admin]) { const denied = await dispatcher.dispatch({ actor: actor({ userId }), - command: "revbank-manage", + command: "revbank", + subgroup: "manage", subcommand: "review", args: { user: IDS.friend }, users: { user: { id: IDS.friend, displayName: "Example", isBot: false } }, @@ -99,6 +101,30 @@ describe("buildCore", () => { expect(denied.reply.text).toBe(MESSAGES.deniedTier); } expect(fetch).toHaveBeenCalledTimes(1); + await dispatcher.dispatch({ + actor: actor({ userId: IDS.admin }), + command: "admin", + subgroup: "capabilities", + subcommand: "grant", + args: { user: IDS.member, capability: REVBANK_LINK_ADMIN.name }, + users: { user: { id: IDS.member, displayName: "Example manager", isBot: false } }, + }); + fetch.mockResolvedValueOnce(Response.json({ state: "unlinked" })); + const allowed = await dispatcher.dispatch({ + actor: actor({ userId: IDS.member }), + command: "revbank", + subgroup: "manage", + subcommand: "review", + args: { user: IDS.friend }, + users: { user: { id: IDS.friend, displayName: "Example", isBot: false } }, + }); + expect(allowed.private).toBe(true); + expect(allowed.reply.text).toBe("No RevBank account is linked."); + expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ + actor: `discord:${IDS.member}`, + target: `discord:${IDS.friend}`, + }); + expect(core.registry.all().map((c) => c.definition.name)).not.toContain("revbank-manage"); }); it.each([ diff --git a/src/features/features.test.ts b/src/features/features.test.ts index 42092e0..0a99944 100644 --- a/src/features/features.test.ts +++ b/src/features/features.test.ts @@ -100,7 +100,6 @@ describe("buildFeatures", () => { "info", "ping", "revbank", - "revbank-manage", "schedule", "spacestate", "status", diff --git a/src/features/revbank/index.ts b/src/features/revbank/index.ts index 554304a..c9023b4 100644 --- a/src/features/revbank/index.ts +++ b/src/features/revbank/index.ts @@ -139,7 +139,7 @@ export function createRevbankFeature(deps: { own("balance", "Show your balance from the latest usable backup", async (api, ctx) => { const result = await api.balance(actorRef(ctx.principal)); return { - text: `Your RevBank balance: **${money(result.balanceCents)}**\nBackup: ${when(result.updatedAt, deps.timezone)}. Purchases since then may not be included.`, + text: `Your RevBank balance: **${money(result.balanceCents)}**\nBackup: ${when(result.updatedAt, deps.timezone)}.`, }; }), own("transactions", "Show your last ten confirmed bookings", async (api, ctx) => { @@ -160,42 +160,42 @@ export function createRevbankFeature(deps: { ], }; }), - ], - }, - { - name: "revbank-manage", - description: "Manage verified RevBank links, without viewing balances", - access: { minTier: "member", capability: REVBANK_LINK_ADMIN.name }, - subcommands: [ - manager( - "review", - "Review a person's link request before verifying ownership", - async (api, ctx) => ({ - text: describe(await api.review(actorRef(ctx.principal), target(ctx))), - }), - ), - manager( - "approve", - "Approve the exact request after verifying ownership in person", - async (api, ctx) => { - await api.decide(actorRef(ctx.principal), target(ctx), requestId(ctx), true); - return { text: "RevBank link approved." }; - }, - true, - ), - manager( - "reject", - "Reject the exact link request", - async (api, ctx) => { - await api.decide(actorRef(ctx.principal), target(ctx), requestId(ctx), false); - return { text: "RevBank request rejected." }; - }, - true, - ), - manager("revoke", "Remove a person's link and pending request", async (api, ctx) => { - await api.revoke(actorRef(ctx.principal), target(ctx)); - return { text: "RevBank link and pending request revoked." }; - }), + { + name: "manage", + description: "Manage verified RevBank links, without viewing balances", + access: { minTier: "member", capability: REVBANK_LINK_ADMIN.name }, + subcommands: [ + manager( + "review", + "Review a person's link request before verifying ownership", + async (api, ctx) => ({ + text: describe(await api.review(actorRef(ctx.principal), target(ctx))), + }), + ), + manager( + "approve", + "Approve the exact request after verifying ownership in person", + async (api, ctx) => { + await api.decide(actorRef(ctx.principal), target(ctx), requestId(ctx), true); + return { text: "RevBank link approved." }; + }, + true, + ), + manager( + "reject", + "Reject the exact link request", + async (api, ctx) => { + await api.decide(actorRef(ctx.principal), target(ctx), requestId(ctx), false); + return { text: "RevBank request rejected." }; + }, + true, + ), + manager("revoke", "Remove a person's link and pending request", async (api, ctx) => { + await api.revoke(actorRef(ctx.principal), target(ctx)); + return { text: "RevBank link and pending request revoked." }; + }), + ], + }, ], }, ], diff --git a/src/features/revbank/revbank.test.ts b/src/features/revbank/revbank.test.ts index caa47b2..1e30a15 100644 --- a/src/features/revbank/revbank.test.ts +++ b/src/features/revbank/revbank.test.ts @@ -29,9 +29,13 @@ const api = () => ({ function setup(client: Revbank | undefined = api()) { const feature = createRevbankFeature({ api: client, timezone: "UTC" }); const command = (name: string, management = false) => { - const group = feature.commands?.[management ? 1 : 0]; + const group = feature.commands?.[0]; if (!group || !isGroup(group)) throw new Error("Missing group"); - const command = group.subcommands.find((c) => c.name === name); + const manage = group.subcommands.find((c) => c.name === "manage"); + if (!manage || !isSubgroup(manage)) throw new Error("Missing manage subgroup"); + const command = (management ? manage.subcommands : group.subcommands).find( + (c) => c.name === name, + ); if (!command || isSubgroup(command)) throw new Error("Missing command"); return command; }; @@ -49,11 +53,15 @@ describe("RevBank commands", () => { for (const group of feature.commands ?? []) { if (!isGroup(group)) throw new Error("Expected group"); for (const command of group.subcommands) { - if (isSubgroup(command)) throw new Error("Expected subcommand"); - expect(command.private).toBe(true); + for (const runnable of isSubgroup(command) ? command.subcommands : [command]) { + expect(runnable.private).toBe(true); + } } } - expect(feature.commands?.[1]?.access).toEqual({ + expect(feature.commands).toHaveLength(1); + const root = feature.commands?.[0]; + if (!root || !isGroup(root)) throw new Error("Missing root"); + expect(root.subcommands.find((c) => c.name === "manage")?.access).toEqual({ minTier: "member", capability: REVBANK_LINK_ADMIN.name, }); @@ -76,7 +84,9 @@ describe("RevBank commands", () => { const { command } = setup(client); await command("link").handler({ ...ctx(), args: { nick: "Example" } }); expect(client.request).toHaveBeenCalledWith(`discord:${IDS.member}`, "Example"); - expect((await command("balance").handler(ctx())).text).toContain("€8.50"); + expect((await command("balance").handler(ctx())).text).toBe( + "Your RevBank balance: **€8.50**\nBackup: 21 Oct 2026, 10:00.", + ); expect( (await command("transactions").handler(ctx())).embeds?.[0]?.fields?.[0]?.value, ).toContain("Cola \\*test\\*"); From 6d12b12fb74c29847334a490df56ba6667185be9 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 14:10:36 +0200 Subject: [PATCH 3/6] Document deploying the private gateway on shared Pixel infrastructure --- docs/adr/0018-private-revbank-gateway.md | 2 +- docs/infra.md | 7 +++++++ docs/runbook.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/adr/0018-private-revbank-gateway.md b/docs/adr/0018-private-revbank-gateway.md index 2752089..dd737c7 100644 --- a/docs/adr/0018-private-revbank-gateway.md +++ b/docs/adr/0018-private-revbank-gateway.md @@ -17,7 +17,7 @@ Pixel calls a separately deployed private gateway. Only that gateway can read a - The gateway reads fixed filenames from one immutable Git commit. It never checks out or executes backup content. Account creation markers bind links to account incarnations, so a missing or recreated account fails closed. Unrecognised formats and account/log inconsistencies fail closed. Historic data is taken from confirmed booking records, not commit messages or Git diffs. - Only the last ten confirmed bookings are exposed. Labels come from recognised catalogue descriptions; other descriptions become generic credit/debit/adjustment labels. Raw terminal input, counterparties and free-form log content never cross the API. - Pixel stores no financial response. HTTP requests have an eight-second deadline, a streamed 16 KiB response cap, strict schemas, no redirects and no automatic retries. Errors contain fixed codes only. There is no financial/request-body logging. -- The gateway is on the private network, reached through HTTPS and a dedicated bearer token. HTTP is allowed only for loopback testing. No public ingress or Tailscale Funnel. Dev must use a separate gateway, token and fictional backup. +- The TypeScript gateway runs as a separate Azure Container App in Pixel’s existing Container Apps environment, with internal HTTPS ingress and a dedicated bearer token. Its private repository provides the deployment recipes and separate Terraform state keys. HTTP is allowed only for loopback testing. No public ingress or Tailscale Funnel. Dev must use a separate gateway, token and fictional backup. ## Consequences and limits diff --git a/docs/infra.md b/docs/infra.md index dddcd30..9d43bbf 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -199,3 +199,10 @@ just deploy dev # this tree → GHCR SHA → pixel-dev, then just regi ``` Do not apply Terraform from CD. Do not copy `pixel-dev-kv` tokens into prod. After first create, **dev** apply ignores the Pixel container image (#73). Prod apply sets `container_image`; a Release then rolls the published SHA. + + +## Optional RevBank gateway + +The private RevBank repository deploys a separate TypeScript/Node.js Container App into the **same resource group and Container Apps environment as Pixel**. Its API uses internal HTTPS ingress, so the Pixel container can reach it without public ingress or another Tailscale route. The gateway owns a private registry, managed identity, Key Vault and separate Azure Files shares for backup data and account links. Pixel receives only the API URL and token. + +The gateway's `just configure dev|prod` imports Pixel's environment settings and discovers the existing Container Apps environment. Its Terraform stacks use the existing remote state account/container with their own `revbank-api/foundation.tfstate` and `revbank-api/app.tfstate` keys. It does not manage the Pixel Container App or Pixel's Terraform state. The private repo documents `just provision`, `just secrets-push`, `just deploy`, rollback and production guards. diff --git a/docs/runbook.md b/docs/runbook.md index b0d6289..cc94ffc 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -678,7 +678,7 @@ If `members.yaml` is lost and there's no backup, Pixel won't start (it fails clo RevBank is optional and off unless both `REVBANK_API_URL` and `REVBANK_API_TOKEN` are set. The URL must be an HTTPS origin; loopback HTTP is allowed for local tests only. The token lives in Key Vault as `revbank-api-token` (app group). Set `revbank_api_url` in the environment's Terraform settings and push the app secret through `just secrets-push dev` before enabling it. Follow the existing reviewed production process for prod. Never put backup credentials or a backup mount in Pixel. -Deploy the gateway from its separate private repository, following its runbook. It reads a local mirror of the existing backup and must not modify RevBank or its backup remote. Use a private network endpoint, HTTPS, explicit network ACLs and separate tokens/state for dev and prod. Dev uses fictional financial data only. This change does not deploy the gateway, configure Tailscale ACLs, grant capabilities or alter live secrets. +Deploy the TypeScript gateway from its separate private repository, following its runbook. Its `just configure`, `just provision` and `just deploy` workflow reuses Pixel’s existing Azure resource group and Container Apps environment. It keeps its own app, private registry, Key Vault, storage and Terraform state keys. Internal HTTPS ingress lets Pixel reach it without another server or Tailscale route. It reads a mirror of the existing backup and must not modify RevBank or its backup remote. Use separate tokens/state for dev and prod. Dev uses fictional financial data only. This change does not deploy the gateway, configure Tailscale ACLs, grant capabilities or alter live secrets. After deployment, register commands with `just register dev` (production uses the Release workflow). Give a verified link manager the `revbank-link-admin` capability with `/admin capabilities grant` and add their immutable Discord ID to the gateway's separate manager allow-list. Both checks must pass; an admin tier alone grants no link-management permission. From 2e5d0c9e5b393672617d39f1c6d3b8b09f29390a Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 14:16:32 +0200 Subject: [PATCH 4/6] Document RevBank on the Pixel website --- CHANGELOG.md | 2 +- .../theme/components/PxCommandTray.vue | 7 +- docs/.vitepress/theme/components/PxHome.vue | 6 ++ docs/commands.md | 64 +++++++++++++++++++ docs/guide/index.md | 13 ++++ docs/guide/tiers.md | 8 +++ 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea0a48..c5c27ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ### Added -- Optional private RevBank gateway integration: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. Pixel never reads the backup or persists financial responses. +- Optional private RevBank gateway integration: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. The website includes the account-linking guide and command reference. Pixel never reads the backup or persists financial responses. - CHANGELOG conflicts resolve themselves when that is safe. When `main`'s `CHANGELOG.md` changes (and after each Release), a new workflow merges `main` into every open PR from this repo that edits it. If `CHANGELOG.md` is the only conflict, it keeps both sides' `[Unreleased]` entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new `[Unreleased]` after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, `just changelog-merge` does the same in a stopped merge or rebase, and `just changelog-merge-driver` makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts"). diff --git a/docs/.vitepress/theme/components/PxCommandTray.vue b/docs/.vitepress/theme/components/PxCommandTray.vue index 1d3e4e0..5bc54c8 100644 --- a/docs/.vitepress/theme/components/PxCommandTray.vue +++ b/docs/.vitepress/theme/components/PxCommandTray.vue @@ -30,7 +30,7 @@ const props = withDefaults( caption?: string; }>(), { - queries: () => ["/status", "/ha", "/info", "/schedule", "/events"], + queries: () => ["/status", "/ha", "/revbank", "/info", "/schedule", "/events"], rows: 5, query: undefined, selected: 0, @@ -53,6 +53,11 @@ const COMMANDS: readonly Command[] = [ description: "Send feedback to Pixel's maintainers. Your message and username may be made public", }, + { name: "/revbank link", description: "Request a verified link to your RevBank account" }, + { name: "/revbank status", description: "Show your RevBank link or pending request" }, + { name: "/revbank balance", description: "Show your balance from the latest usable backup" }, + { name: "/revbank transactions", description: "Show your last ten confirmed bookings" }, + { name: "/revbank unlink", description: "Remove your link and any pending request" }, { name: "/closing-time", description: "Post the closing-time reminder" }, { name: "/ha list", description: "The devices you may use, and their state" }, { name: "/ha status", description: "The live state of one device" }, diff --git a/docs/.vitepress/theme/components/PxHome.vue b/docs/.vitepress/theme/components/PxHome.vue index 0502ba1..d13f6bb 100644 --- a/docs/.vitepress/theme/components/PxHome.vue +++ b/docs/.vitepress/theme/components/PxHome.vue @@ -20,6 +20,12 @@ const features = [ body: "/events lists what's on at Pixelbar, from the Discord server's scheduled events.", link: "/commands#events", }, + { + eyebrow: "RevBank", + title: "Your credits, in private", + body: "Link your RevBank account once, then check your balance and latest bookings right in Discord.", + link: "/guide/#your-revbank-account", + }, { eyebrow: "Info", title: "How do I join?", diff --git a/docs/commands.md b/docs/commands.md index 77e940b..82f08f8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,6 +53,42 @@ Sends a message to Pixel's maintainers, with your Discord name and ID so they ca +### RevBank + +Your credits and latest bookings, in private. [Link your account once](/guide/#your-revbank-account), then check it from Discord. + + + + + +Requests a link to your RevBank account. A link manager verifies that the account is yours and approves the request. + + + + + +Shows your linked account or pending request. + + + + + +Your current balance in the latest available backup, with its date and time. + + + + + +Your last ten confirmed bookings, including the amount and balance after each booking. + + + + + +Removes your account link and any pending request. + + + ## Members For paid members. Commands marked Friend also work with a friend membership. @@ -63,6 +99,34 @@ Posts the closing-time reminder in the closing-time channel. Pixel also posts it +### RevBank link management + +For members with the `revbank-link-admin` capability who are also authorised by the RevBank service. Link managers verify account ownership and manage links; they do not gain access to other people's balances. + + + +Shows a person's linked account or pending request, including its request ID. + + + + + +Approves the reviewed request after verifying account ownership in person. + + + + + +Rejects the reviewed request. + + + + + +Removes a person's link and pending request. + + + ### Home Assistant Lights, switches and doors in the space. You only ever see the devices you're allowed to see, and Pixel checks again whatever autocomplete offered. diff --git a/docs/guide/index.md b/docs/guide/index.md index e96701b..b5d4eaf 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -13,10 +13,23 @@ A few you'll use most: - `/status`: is Pixelbar open right now, and for how long? - `/events`: what's coming up. - `/info`: short answers about Pixelbar. With no topic, it lists them. +- `/revbank balance`: your credits, once your RevBank account is linked. - `/help`: the commands you can use. It only lists the ones your tier allows. The [commands](/commands) page has every command, who can use it and what it needs. +## Your RevBank account + +When RevBank is enabled, Pixel can show your credits and latest bookings privately in Discord. + +1. Select `/revbank link` and fill in your RevBank nickname in the **nick** field. +2. Ask a link manager in the space to verify that the account is yours and approve your request. +3. Use `/revbank balance` for your credits or `/revbank transactions` for your last ten bookings. + +`/revbank status` shows your link or pending request. You can remove it yourself with `/revbank unlink`. Linking a RevBank account works for guests too; it does not change your Pixel membership tier. + +[All RevBank commands](/commands#revbank) · [Privacy notice](/privacy#optional-revbank-integration) + ## Who sees the reply Every command says how it replies. diff --git a/docs/guide/tiers.md b/docs/guide/tiers.md index a8c9fd9..1b20579 100644 --- a/docs/guide/tiers.md +++ b/docs/guide/tiers.md @@ -28,6 +28,7 @@ A capability is a named permission for one person, for things that shouldn't rea | Capability | What it allows | | --- | --- | | `schedule-posts` | Schedule messages and polls in channels you can post in, with `/schedule`. | +| `revbank-link-admin` | Review, approve and revoke account links with `/revbank manage`. Members only, with separate authorisation from the RevBank service. | | `spacestate` | Set the space open or closed, with `/spacestate set`. Members only. | | `ha-lights` | Change lights in Home Assistant, with `/ha set`. | | `ha-switches` | Change switches in Home Assistant, with `/ha set`. | @@ -41,3 +42,10 @@ A capability is a named permission for one person, for things that shouldn't rea ## Home Assistant devices Each device has its own minimum tier to be used. Seeing a device and its state needs only that tier. Changing it needs the tier, plus `ha-admin` or the capability for that kind of device, plus an action that device allows. + + +## Your RevBank account + +You do not need a paid membership to link your own RevBank account. A link manager verifies and approves the link before Pixel can show your balance or bookings. Managing links requires the separate `revbank-link-admin` capability and service authorisation; it does not give access to another person's financial data. + +[Set up your account link](/guide/#your-revbank-account). From 8fd09d8dffb8c6aebf4455ac1c3bf03dea24a573 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 14:22:16 +0200 Subject: [PATCH 5/6] Require Friend tier for personal RevBank commands --- CHANGELOG.md | 2 +- docs/adr/0018-private-revbank-gateway.md | 2 +- docs/architecture.md | 2 +- docs/commands.md | 14 +++++++---- docs/guide/index.md | 4 ++-- docs/guide/tiers.md | 2 +- docs/identity-and-access.md | 2 +- docs/runbook.md | 2 +- src/app.test.ts | 30 ++++++++++++++++++++++-- src/features/revbank/index.ts | 4 ++-- src/features/revbank/revbank.test.ts | 4 ++++ 11 files changed, 51 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c27ab..491280e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ### Added -- Optional private RevBank gateway integration: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. The website includes the account-linking guide and command reference. Pixel never reads the backup or persists financial responses. +- Optional private RevBank gateway integration for friends, members and admins: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. The website includes the account-linking guide and command reference. Pixel never reads the backup or persists financial responses. - CHANGELOG conflicts resolve themselves when that is safe. When `main`'s `CHANGELOG.md` changes (and after each Release), a new workflow merges `main` into every open PR from this repo that edits it. If `CHANGELOG.md` is the only conflict, it keeps both sides' `[Unreleased]` entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new `[Unreleased]` after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, `just changelog-merge` does the same in a stopped merge or rebase, and `just changelog-merge-driver` makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts"). diff --git a/docs/adr/0018-private-revbank-gateway.md b/docs/adr/0018-private-revbank-gateway.md index dd737c7..3fa177c 100644 --- a/docs/adr/0018-private-revbank-gateway.md +++ b/docs/adr/0018-private-revbank-gateway.md @@ -10,7 +10,7 @@ People want to read their own prepaid balance and bookings in Pixel. RevBank and Pixel calls a separately deployed private gateway. Only that gateway can read a local mirror of the backup. Pixel has no Git credentials, backup mount, account catalogue, or persisted financial data. The gateway's code and operations live in a separate private repository. Public code contains only the typed API port, HTTP client, commands and fictional fixtures. -- Personal commands are available to guests: a RevBank account does not grant Pixel membership, and membership does not grant access to a RevBank account. +- Personal commands require at least friend tier: friends, members and admins can use them, while guests cannot. A RevBank account does not grant Pixel membership, and membership does not grant access to a RevBank account. - The authenticated Discord actor is the only identity used for personal requests. There is no personal `user` or `nick` selector on balance/history requests. Replies and failures are private from the initial deferral onward. - A request carries a nickname, expires after 24 hours and receives a unique request ID. It reveals no account existence. A manager verifies ownership independently in person, then approves that exact request for the selected Discord user. Self-approval is refused by the gateway. - Link management requires the `revbank-link-admin` capability at member tier in Pixel **and** a separately maintained immutable-ID allow-list in the gateway. Managers can inspect pending/link metadata and approve, reject or revoke; they cannot read another person's balance. Approvals enforce one Discord identity per account. Unlinking is available to the owner without a manager. diff --git a/docs/architecture.md b/docs/architecture.md index 900af65..13e7529 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -590,7 +590,7 @@ Record significant decisions as short ADRs in `docs/adr/NNNN-title.md`. Numbers ## Personal RevBank access -The optional RevBank integration reads personal balances and the last ten bookings through a separately hosted private gateway. Pixel never reads the Git backup itself. The gateway owns link requests, approvals, immutable Discord-ID bindings and financial-data filtering. All `/revbank` replies are private. Guests may request their own link; managing links requires the `revbank-link-admin` capability and independent authorisation in the gateway. No financial writes or local cache. See [ADR 0018](adr/0018-private-revbank-gateway.md) and the [runbook](runbook.md#revbank). +The optional RevBank integration reads personal balances and the last ten bookings through a separately hosted private gateway. Pixel never reads the Git backup itself. The gateway owns link requests, approvals, immutable Discord-ID bindings and financial-data filtering. All `/revbank` replies are private. Friends, members and admins may request their own link; managing links requires the `revbank-link-admin` capability and independent authorisation in the gateway. No financial writes or local cache. See [ADR 0018](adr/0018-private-revbank-gateway.md) and the [runbook](runbook.md#revbank). ```mermaid flowchart LR diff --git a/docs/commands.md b/docs/commands.md index 82f08f8..ca9ba6d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,37 +53,41 @@ Sends a message to Pixel's maintainers, with your Discord name and ID so they ca +## Friends + +For friends, members and admins. + ### RevBank Your credits and latest bookings, in private. [Link your account once](/guide/#your-revbank-account), then check it from Discord. - + Requests a link to your RevBank account. A link manager verifies that the account is yours and approves the request. - + Shows your linked account or pending request. - + Your current balance in the latest available backup, with its date and time. - + Your last ten confirmed bookings, including the amount and balance after each booking. - + Removes your account link and any pending request. diff --git a/docs/guide/index.md b/docs/guide/index.md index b5d4eaf..b582354 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -20,13 +20,13 @@ The [commands](/commands) page has every command, who can use it and what it nee ## Your RevBank account -When RevBank is enabled, Pixel can show your credits and latest bookings privately in Discord. +When RevBank is enabled, friends, members and admins can check their credits and latest bookings privately in Discord. 1. Select `/revbank link` and fill in your RevBank nickname in the **nick** field. 2. Ask a link manager in the space to verify that the account is yours and approve your request. 3. Use `/revbank balance` for your credits or `/revbank transactions` for your last ten bookings. -`/revbank status` shows your link or pending request. You can remove it yourself with `/revbank unlink`. Linking a RevBank account works for guests too; it does not change your Pixel membership tier. +`/revbank status` shows your link or pending request. You can remove it yourself with `/revbank unlink`. You need at least the Friend tier to use RevBank. Linking an account does not change your Pixel membership tier. [All RevBank commands](/commands#revbank) · [Privacy notice](/privacy#optional-revbank-integration) diff --git a/docs/guide/tiers.md b/docs/guide/tiers.md index 1b20579..c226695 100644 --- a/docs/guide/tiers.md +++ b/docs/guide/tiers.md @@ -46,6 +46,6 @@ Each device has its own minimum tier to be used. Seeing a device and its state n ## Your RevBank account -You do not need a paid membership to link your own RevBank account. A link manager verifies and approves the link before Pixel can show your balance or bookings. Managing links requires the separate `revbank-link-admin` capability and service authorisation; it does not give access to another person's financial data. +RevBank is available to friends, members and admins. Guests cannot use the RevBank commands. A link manager verifies and approves the link before Pixel can show your balance or bookings. Managing links requires the separate `revbank-link-admin` capability and service authorisation; it does not give access to another person's financial data. [Set up your account link](/guide/#your-revbank-account). diff --git a/docs/identity-and-access.md b/docs/identity-and-access.md index 808bb07..ec2ea3c 100644 --- a/docs/identity-and-access.md +++ b/docs/identity-and-access.md @@ -236,4 +236,4 @@ These are recorded so that phase 1 doesn't make them harder. Each will get an AD ## RevBank links -RevBank links are separate from membership and platform-account linking. The private gateway, not `members.yaml`, stores the approved mapping from an immutable Discord ID to a RevBank account. `/revbank` personal commands are guest-accessible but the gateway returns financial data only after independent ownership verification. `/revbank manage` requires member tier plus `revbank-link-admin`; the gateway separately checks its manager ID allow-list. Link managers have no financial-read override. See [ADR 0018](adr/0018-private-revbank-gateway.md). +RevBank links are separate from membership and platform-account linking. The private gateway, not `members.yaml`, stores the approved mapping from an immutable Discord ID to a RevBank account. `/revbank` personal commands require at least friend tier but the gateway returns financial data only after independent ownership verification. `/revbank manage` requires member tier plus `revbank-link-admin`; the gateway separately checks its manager ID allow-list. Link managers have no financial-read override. See [ADR 0018](adr/0018-private-revbank-gateway.md). diff --git a/docs/runbook.md b/docs/runbook.md index cc94ffc..19d183a 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -682,7 +682,7 @@ Deploy the TypeScript gateway from its separate private repository, following it After deployment, register commands with `just register dev` (production uses the Release workflow). Give a verified link manager the `revbank-link-admin` capability with `/admin capabilities grant` and add their immutable Discord ID to the gateway's separate manager allow-list. Both checks must pass; an admin tier alone grants no link-management permission. -1. The owner selects `/revbank link`, fills in their RevBank nickname in Discord’s required **nick** field, and contacts a link manager in person. +1. The owner must have at least Friend tier. They select `/revbank link`, fills in their RevBank nickname in Discord’s required **nick** field, and contacts a link manager in person. 2. The manager verifies the account belongs to that person, uses `/revbank manage review user:`, and checks the nickname and request ID. 3. The manager uses `/revbank manage approve user: request:` with that exact ID. Requests expire after 24 hours; an edited/replaced request needs a fresh review. Managers cannot approve themselves. 4. The owner checks `/revbank status`, `/revbank balance` and `/revbank transactions`. Replies are private, sourced from the backup, and include its timestamp. diff --git a/src/app.test.ts b/src/app.test.ts index c28a9c0..479994d 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -71,6 +71,32 @@ describe("buildCore", () => { afterEach(() => rmSync(dir, { recursive: true, force: true })); + it.each( + (["guest", "friend", "member", "admin"] as const).flatMap((tier) => + ["link", "status", "balance", "transactions", "unlink"].map((subcommand) => ({ + tier, + subcommand, + })), + ), + )("RevBank $subcommand requires friend tier: $tier", async ({ tier, subcommand }) => { + writeFileSync( + config.access.membersFile, + `${readFileSync(config.access.membersFile, "utf8")} - ids: ["discord:${IDS.friend}"]\n tier: friend\n`, + ); + const fetch = vi.fn(); + const { dispatcher } = buildCore(config, silentLogger, nullErrorReporter, { fetch }); + const result = await dispatcher.dispatch({ + actor: actor({ userId: IDS[tier] }), + command: "revbank", + subcommand, + args: subcommand === "link" ? { nick: "Example" } : {}, + }); + expect(result.private).toBe(true); + expect(result.reply.text).toBe( + tier === "guest" ? MESSAGES.deniedTier : "RevBank isn't set up here.", + ); + expect(fetch).not.toHaveBeenCalled(); + }); it("wires private RevBank reads and gates link management independently of admin tier", async () => { config.revbank = { url: "https://gateway.example", token: "x".repeat(32) }; const fetch = vi @@ -79,7 +105,7 @@ describe("buildCore", () => { const core = buildCore(config, silentLogger, nullErrorReporter, { fetch }); const { dispatcher } = core; const balance = await dispatcher.dispatch({ - actor: actor({ userId: IDS.guest }), + actor: actor({ userId: IDS.member }), command: "revbank", subcommand: "balance", args: {}, @@ -87,7 +113,7 @@ describe("buildCore", () => { expect(balance.private).toBe(true); expect(balance.reply.text).toContain("€8.50"); expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({ - actor: `discord:${IDS.guest}`, + actor: `discord:${IDS.member}`, }); for (const userId of [IDS.guest, IDS.member, IDS.admin]) { const denied = await dispatcher.dispatch({ diff --git a/src/features/revbank/index.ts b/src/features/revbank/index.ts index c9023b4..7075903 100644 --- a/src/features/revbank/index.ts +++ b/src/features/revbank/index.ts @@ -54,7 +54,7 @@ export function createRevbankFeature(deps: { ): SubcommandDefinition => ({ name, description, - access: { minTier: "guest" }, + access: { minTier: "friend" }, private: true, handler: run(action), }); @@ -109,7 +109,7 @@ export function createRevbankFeature(deps: { { name: "revbank", description: "Your private RevBank account", - access: { minTier: "guest" }, + access: { minTier: "friend" }, subcommands: [ own("status", "Show your RevBank link or pending request", async (api, ctx) => ({ text: describe(await api.status(actorRef(ctx.principal))), diff --git a/src/features/revbank/revbank.test.ts b/src/features/revbank/revbank.test.ts index 1e30a15..b9ca568 100644 --- a/src/features/revbank/revbank.test.ts +++ b/src/features/revbank/revbank.test.ts @@ -61,6 +61,10 @@ describe("RevBank commands", () => { expect(feature.commands).toHaveLength(1); const root = feature.commands?.[0]; if (!root || !isGroup(root)) throw new Error("Missing root"); + expect(root.access).toEqual({ minTier: "friend" }); + for (const command of root.subcommands) { + if (!isSubgroup(command)) expect(command.access).toEqual({ minTier: "friend" }); + } expect(root.subcommands.find((c) => c.name === "manage")?.access).toEqual({ minTier: "member", capability: REVBANK_LINK_ADMIN.name, From f1ecb55f2864c2918293c117d1b4a1793d277ede Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Oct 2026 14:23:06 +0200 Subject: [PATCH 6/6] Handle malformed RevBank URLs as configuration errors --- CHANGELOG.md | 2 +- src/config.test.ts | 14 ++++++++++++++ src/config.ts | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491280e..cca2856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ### Added -- Optional private RevBank gateway integration for friends, members and admins: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. The website includes the account-linking guide and command reference. Pixel never reads the backup or persists financial responses. +- Optional private RevBank gateway integration for friends, members and admins: verified link requests, private balance and last-ten-booking commands, owner unlinking, and separately authorised `/revbank manage` commands. The website includes the account-linking guide and command reference. Pixel never reads the backup or persists financial responses. Invalid gateway URLs produce a clear configuration error without exposing the value. - CHANGELOG conflicts resolve themselves when that is safe. When `main`'s `CHANGELOG.md` changes (and after each Release), a new workflow merges `main` into every open PR from this repo that edits it. If `CHANGELOG.md` is the only conflict, it keeps both sides' `[Unreleased]` entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new `[Unreleased]` after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, `just changelog-merge` does the same in a stopped merge or rebase, and `just changelog-merge-driver` makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts"). diff --git a/src/config.test.ts b/src/config.test.ts index 57c41c5..8122354 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -585,6 +585,20 @@ describe("the bot status channel and build info", () => { }); describe("RevBank configuration", () => { + it.each(["example.com", "not a URL", "https://[invalid", "https://gateway.example:invalid"])( + "reports malformed URL %s as a redacted ConfigError", + (url) => { + let caught: unknown; + try { + loadConfig({ ...VALID, REVBANK_API_URL: url, REVBANK_API_TOKEN: "x".repeat(32) }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigError); + expect((caught as Error).message).toContain("REVBANK_API_URL"); + expect((caught as Error).message).not.toContain(url); + }, + ); it("is off by default and requires URL and token together", () => { expect(loadConfig(VALID).revbank).toBeUndefined(); for (const partial of [ diff --git a/src/config.ts b/src/config.ts index fdab200..a3f6fe7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,7 @@ const extraHeaders = z.string().transform((raw, ctx) => { const revbankUrl = z.url({ protocol: /^https?$/ }).refine( (value) => { + if (!URL.canParse(value)) return false; const url = new URL(value); return ( !url.username &&