Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Keeps the build context small for `docker build -f backend/brico-bot/Dockerfile .` — the
# Dockerfile only COPYs package.json/package-lock.json, common/{bindings,crafts,bitcraft-bindings}
# and backend/brico-bot, but everything else in the repo still gets sent to the Docker daemon as
# build context unless excluded here.
.git
.github
.idea
.claude
node_modules
**/node_modules
docs

# Not needed by brico-bot's image (see its Dockerfile's header comment) and by far the largest
# thing in the repo otherwise.
frontend

backend/brico-bot/.state
backend/brico-bot/.env
44 changes: 43 additions & 1 deletion .github/workflows/docker-build-brico-bot.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
name: Build brico-bot docker image
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "backend/brico-bot/**"
- "common/bindings/**"
- "common/crafts/**"
- "common/bitcraft-bindings/**"
- "package.json"
- "package-lock.json"

jobs:
build-brico-bot:
runs-on: ubuntu-latest
env:
REGISTRY: ghcr.io
IMAGE_NAME: bitcrafttoolbox/brico-bot
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- run:
- uses: actions/checkout@v6
with:
# common/bitcraft-bindings/upstream is a submodule the image build actually needs
# (@brico/bitcraft-bindings exports its raw .ts); frontend/public/bsatn comes along too
# but is small and unused by this build.
submodules: true
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: BitCraftToolBox
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: backend/brico-bot/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
github-token: "${{ secrets.GITHUB_TOKEN }}"
6 changes: 3 additions & 3 deletions .github/workflows/i18n-translate-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
git diff --cached --quiet || git commit -m "chore: refresh i18n catalog (translate-sync backstop)"

- name: Upload sources to Crowdin
uses: crowdin/github-action@v3.0.0-next.2
uses: crowdin/github-action@v3.2.0
with:
upload_sources: true
upload_translations: false
Expand Down Expand Up @@ -92,7 +92,7 @@ jobs:
run: npm ci

- name: Download translations from Crowdin
uses: crowdin/github-action@v3.0.0-next.2
uses: crowdin/github-action@v3.2.0
with:
upload_sources: false
upload_translations: false
Expand All @@ -114,7 +114,7 @@ jobs:
# after the download above: anything still empty locally was just confirmed empty on
# Crowdin, so this can't clobber a translator's more recent edit.
- name: Upload backfilled translations to Crowdin
uses: crowdin/github-action@v3.0.0-next.2
uses: crowdin/github-action@v3.2.0
with:
upload_sources: false
upload_translations: true
Expand Down
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
# Dependencies. npm workspaces hoist to the repo root.
# Dependencies. npm workspaces hoist to the repo root; backend/brico-module keeps its own
# nested install because it is deliberately outside the workspace (see common/README.md).
node_modules/

# IDEs and editors
/.idea

# System files
.DS_Store
Thumbs.db

# used by main branch until craft manager lands
frontend/src/bindings
# used only for local ssr testing - CF has this set on the project settings
frontend/wrangler.toml
4 changes: 2 additions & 2 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
path = frontend/public/bsatn
url = https://github.com/BitCraftToolBox/BitCraft_BSATN_Data
[submodule "frontend/src/bindings"]
path = frontend/src/bindings
path = common/bitcraft-bindings/upstream
url = https://github.com/BitCraftToolBox/BitCraft_Bindings
branch = ts-region
branch = ts-region-2
67 changes: 67 additions & 0 deletions backend/brico-bot/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# brico-bot configuration. Copy to `.env` (gitignored) and edit; real environment variables win
# over anything set here.

# ── prism's relay-module (the BitCraft read-mirror) ──────────────────────────
#BRICO_RELAY_HOST=http://127.0.0.1:3000
#BRICO_RELAY_MODULE=prism-relay

# ── brico-app (brico's own module) ───────────────────────────────────────────
#BRICO_APP_ENABLED=1
#BRICO_APP_HOST=http://127.0.0.1:3000
#BRICO_APP_MODULE=brico-app

# ── bridge behaviour ────────────────────────────────────────────────────────
#BRICO_BOT_SNAPSHOT_INTERVAL_MS=1000
#BRICO_BOT_RECONCILE_INTERVAL_MS=300000
#BRICO_BOT_RECONNECT_DELAY_MS=5000
#BRICO_BOT_RECONNECT_MAX_DELAY_MS=60000
#BRICO_BOT_HEARTBEAT_MS=60000
#BRICO_BOT_LOG_LEVEL=info

# ── paths (relative paths resolve against backend/brico-bot/) ───────────────
#BRICO_BOT_STATE_DIR=.state

# CDN that item/frame icon paths resolve against (Discord watch display thumbnails).
#BRICO_ASSET_CDN_BASE=https://cdn.brico.app

# ── BitAuth/Discord link callback HTTP server ────────────────────────────────
# Bind address and public base URL of the HTTP server (/auth/*, /icons/*, /discord/*, /metrics).
# Code defaults are container-friendly (0.0.0.0:8080, https://bot.brico.app); override all three
# when running bare on a workstation.
#BRICO_BOT_HTTP_HOST=127.0.0.1
#BRICO_BOT_HTTP_PORT=8787
#BRICO_BOT_HTTP_BASE_URL=http://127.0.0.1:8787
# Where the browser lands after a link attempt (?linked=<provider> or ?linkError=<message> appended).
#BRICO_BOT_LINK_RETURN_URL=http://localhost:3001/account/profile
# Comma-separated extra origins a login request's &returnUrl may redirect back to; the origin of
# BRICO_BOT_LINK_RETURN_URL is always allowed, anything else falls back to it.
#BRICO_BOT_LINK_RETURN_URL_ALLOWED_ORIGINS=

# ── BitAuth (BitCraft account linking) ───────────────────────────────────────
# Both unset (the default) disables BitAuth linking entirely — /auth/bitauth/* returns 503 rather
# than the process failing to start. Register brico-bot's callback URL
# (BITAUTH_REDIRECT_URI, or BRICO_BOT_HTTP_BASE_URL + /auth/bitauth/callback) with BitAuth first.
#BITAUTH_ISSUER=https://auth.trinit.is
#BITAUTH_CLIENT_ID=
#BITAUTH_CLIENT_SECRET=
#BITAUTH_REDIRECT_URI=
#BITAUTH_SCOPE=openid profile

# ── Discord OAuth (account linking for a non-Discord brico login) ───────────
# Both unset (the default) disables this route — the SpacetimeAuth-login shortcut and the
# bot-initiated /link flow are unaffected. Register the callback URL as a Discord OAuth2 redirect.
#DISCORD_OAUTH_CLIENT_ID=
#DISCORD_OAUTH_CLIENT_SECRET=
#DISCORD_OAUTH_REDIRECT_URI=

# ── Discord bot (slash commands) ─────────────────────────────────────────────
# All three unset disables the bot (/discord/interactions returns 503). Interactions arrive as signed
# HTTP POSTs (no gateway); set the Developer Portal Interactions Endpoint URL to
# BRICO_BOT_HTTP_BASE_URL + /discord/interactions. PUBLIC_KEY is the application's Ed25519 key.
#DISCORD_BOT_TOKEN=
#DISCORD_PUBLIC_KEY=
#DISCORD_APPLICATION_ID=
# OAuth2 client secret; only needed for the self-serve /discord/install flow (unset: that route 503s).
#DISCORD_BOT_CLIENT_SECRET=
# Guild that `npm run discord:register-commands` registers to; not read by the bot process.
#DISCORD_TEST_GUILD_ID=
4 changes: 4 additions & 0 deletions backend/brico-bot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Connection tokens — credentials, one file per (uri, database). See src/spacetime/token-store.ts.
.state/
# Local configuration
.env
27 changes: 27 additions & 0 deletions backend/brico-bot/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Build context is the REPO ROOT, not this directory — npm workspaces means the lockfile and the
# common/ packages this depends on live outside backend/brico-bot/. Build from the repo root with:
# docker build -f backend/brico-bot/Dockerfile -t brico-bot .
FROM node:24-alpine

LABEL org.opencontainers.image.source="https://github.com/BitCraftToolBox/brico"

WORKDIR /app

COPY package.json package-lock.json ./
COPY common/bindings common/bindings
COPY common/crafts common/crafts
COPY common/bitcraft-bindings common/bitcraft-bindings
COPY backend/brico-bot backend/brico-bot

# `frontend` is a declared workspace too, but npm workspaces silently skips a workspace glob entry
# with no package.json at that path
RUN npm ci --omit=dev && npm cache clean --force

WORKDIR /app/backend/brico-bot
RUN mkdir -p .state && chown -R node:node .state
USER node

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${BRICO_BOT_HTTP_PORT:-8080}/healthz" || exit 1

ENTRYPOINT ["npx", "tsx", "src/main.ts"]
155 changes: 155 additions & 0 deletions backend/brico-bot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# `brico-bot` — the bridge worker

A **long-running Node process**, not a Cloudflare Worker and not a request handler. It holds two
live SpacetimeDB connections at once and runs brico's shared filter engine across both:

```
prism relay-module ──▶┐
craft_meta │
craft_progress ├──▶ brico-bot ──▶ bounty engine ──▶ craft_bounty_assignment/_entitlement
craft_contribution │ (one process, two DbConnections,
claim_info/_member │ + static CraftingRecipeDesc read once from offline BSATN)
player_state,region │
├──▶ @brico/crafts/filter ──▶ watch matches ──▶ notification rows
brico-app ────────────┘ (logged too, always)
all_account, all_linked_integration, all_saved_craft_filter, all_craft_filter_watch,
all_bounty_rule, all_craft_bounty_override/_assignment/_entitlement, all_loyalty_reward
```

Individual SpacetimeDB modules cannot subscribe to each other, so joining prism's world data to
brico's per-user watches (and, separately, resolving bounties against prism's craft/claim ownership)
has to happen in a **client** holding both connections. That client is this service. It also runs a
small persistent HTTP server alongside the two connections — BitAuth's OIDC relying party and
Discord's OAuth-handshake linking route, both of which finalize into `brico-app`'s
`noteIntegrationLinkExternal` reducer (see "BitAuth/Discord account linking" below), plus `/healthz`
and a Prometheus `/metrics` endpoint (see "Metrics" below). The Discord bot itself (slash commands,
guild installation), Stelo's relying-party flow, and a report HTTP API are not implemented yet.

## Running it

From the repo root (npm workspaces — install from the root, never from here):

```sh
npm install
npm start --workspace backend/brico-bot # or: npm run dev --workspace backend/brico-bot
npm test --workspace backend/brico-bot
npm run typecheck --workspace backend/brico-bot
```

Copy `.env.example` to `.env` (gitignored) to change configuration.

### Runtime: `tsx`, not bare `node`

The `common/` workspace packages export raw `.ts` with no build step, and
`@brico/bitcraft-bindings` emits `export namespace`, which Node's strip-only TS loader rejects with
`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. So the runtime is `tsx` — for `npm start`, `npm run dev` and
the tests (`node --import tsx --test`) alike. `tsc` is only ever run with `--noEmit`; there is no
build output and no `dist/`. A real `tsc` build stays a viable swap later (`moduleResolution` would
have to move off `bundler`), but nothing needs it today.

## Configuration

Every variable is optional. Real environment variables beat `.env`. Note the names are **not** the
frontend's `VITE_*` ones: this process is not built by Vite, does not read `frontend/.env.local`,
and reusing those names would imply otherwise.

## BitAuth/Discord account linking

`src/http-server.ts` serves four linking routes, all driven by `brico-app`'s shared
`integration_link_request` mechanism (plus `/healthz` and `/metrics`, see "Metrics" below):

- `/auth/bitauth/login?linkCode=<code>` and `/auth/bitauth/callback` — BitAuth's Authorization Code
+ PKCE (S256) flow. `src/auth/oidc-client.ts` does OIDC discovery against `BITAUTH_ISSUER`
(`/.well-known/openid-configuration`) rather than hardcoding BitAuth's `/connect/*` paths, and
validates the returned ID token against the issuer's live JWKS with
[`jose`](https://github.com/panva/jose). **The `iss` claim BitAuth's discovery document declares
is `https://auth.trinit.is/` (trailing slash) — the JWT verification issuer is always read from
that discovery document, never from the configured `BITAUTH_ISSUER` string, so this can't silently
mismatch.**
- `/auth/discord/login?linkCode=<code>` and `/auth/discord/callback` — Discord's OAuth-handshake
variant (`src/auth/discord-oauth.ts`), plain OAuth2 against `/users/@me` (no ID token, no PKCE —
Discord's authorization-code flow doesn't require it for a confidential client). This is
independent of `linkDiscordViaSpacetimeAuth` (the module-side shortcut for a Discord-login brico
account) and of the bot-initiated `/link` slash command (not implemented yet — needs a real
discord.js bot process).

Both callbacks correlate the OAuth `state` param back to the link `code` a browser already created
via `beginIntegrationLink` using `src/auth/link-state-store.ts`, an in-memory, single-process TTL
map — separate from `integration_link_request` because neither BitAuth nor Discord know brico's link
codes, only the OAuth `state` this process minted for them. Either callback finalizes by calling
`noteIntegrationLinkExternal({code, externalId, externalHandle})` on the live `brico-app` connection
this process already holds, then redirects the browser back with `?linked=<provider>` or
`?linkError=<message>` using a provided `returnUrl`.

## Metrics

`GET /metrics` on the same HTTP server (`src/http-server.ts`) serves a Prometheus exposition
(`src/metrics.ts`, [`prom-client`](https://github.com/siimon/prom-client)) alongside the
process's default Node metrics (`collectDefaultMetrics`). It covers the things that actually vary
with load — snapshot build time, watch/bounty evaluation time, reducer call latency by reducer
name, and watch match counts — deliberately **not** row counts (SpacetimeDB's own metrics already
cover table sizes) and deliberately **not** anything labeled by watch id: watch ids come from
user-created saved filters, an unbounded set.

## `brico-app` visibility — the one non-obvious setup step

Every `brico-app` table is private; clients read through **views**. `my_*` views (`my_account`,
`my_linked_integration`, `my_saved_craft_filter`, `my_craft_filter_watch`) are filtered to
`ctx.sender`; the `all_*` views are the unfiltered feed built for this service, and they return
**no rows** unless the connection's identity is registered in `service_principal`. An empty
`all_account` is therefore an *authorization* result, not an error —
the process logs the exact command to fix it, including its own identity:

```sh
spacetime call --server local brico-app register_service_principal '"<identity-hex>"' '"brico-bot"'
```

The identity is stable across restarts because the connection token is persisted under
`BRICO_BOT_STATE_DIR` (one file per uri+database, mode 0600 — these are credentials; delete a file
to get a fresh identity). Running as the database owner also works and needs no registration.

## Layout

| Path | What it is |
| --- | --- |
| `src/main.ts` | Entrypoint: wires both connections, the bridge, sinks and the HTTP server; signal handling. |
| `src/config.ts` | Every env knob, in one place. |
| `src/log.ts` | Tiny leveled/scoped logger. |
| `src/metrics.ts` | Prometheus counters/histograms, served by `http-server.ts`'s `/metrics` route. |
| `src/bridge.ts` | The bridge: relay snapshots × watches → `evaluateFilter` → added/finished/removed events, with bounty assignment run first each tick so watches see live `payout`/`currency`. |
| `src/bridge.test.ts` | Match-transition tests against synthetic snapshots. |
| `src/spacetime/connection.ts` | Module-agnostic supervised connection: token store, backoff, generation-guarded callbacks. |
| `src/spacetime/token-store.ts` | Filesystem token persistence (the Node stand-in for `localStorage`). |
| `src/spacetime/coalesce.ts` | "Rebuild at most once per interval, and only if something changed." |
| `src/relay/prism.ts` | prism connection + coalesced `CraftSnapshot`, including `craft_contribution`. |
| `src/relay/subject.ts` | Relay rows + static recipe data → `CraftSubject` for the filter engine. |
| `src/game-data/load.ts` | Reads one offline BSATN static-data table straight off disk. |
| `src/game-data/recipes.ts` | `CraftingRecipeDesc` → `RecipeIndex` (effort/skill/level/output item), loaded once at startup. |
| `src/app/connection.ts` | `brico-app` connection and its table registry. |
| `src/app/watch-source.ts` | Where watches come from: `brico-app` accounts, a JSON file, or built-in defaults. |
| `src/app/notification-sink.ts` | Turns a fired watch into a `notification` row on `brico-app` via `postCraftNotification`. |
| `src/app/app-cache.ts` | The bounty-related `brico-app` tables mirrored from row callbacks, with a per-tick delta of what changed. |
| `src/app/bounty-sink.ts` | Per tick, over the changed crafts and ledger rows only: resolves each craft's bounty, prices contributor effort, and resolves loyalty bonuses, writing `craft_bounty_assignment`/`craft_bounty_entitlement`/`loyalty_bonus_total`. |
| `src/spacetime/feed.ts` | Row-callback wiring shared by the prism row cache and the `brico-app` cache. |
| `src/http-server.ts` | The BitAuth/Discord link callback HTTP server. |
| `src/auth/oidc-client.ts` | Generic OIDC Authorization Code + PKCE relying-party client, used for BitAuth. |
| `src/auth/discord-oauth.ts` | Discord's plain-OAuth2 account-linking handshake. |
| `src/auth/pkce.ts` | PKCE (S256) and OAuth `state` generation. |
| `src/auth/link-state-store.ts` | In-memory TTL map correlating an OAuth `state` to a pending `brico-app` link code. |

Shared code is imported **by package name**, never by relative path or a tsconfig alias:
`@brico/crafts/filter`, `@brico/bindings/prism`, `@brico/bindings/brico-app`. The filter engine is
**not** vendored here — byte-for-byte agreement with the frontend's matching behaviour is the entire
reason it is a workspace package. The declared `spacetimedb` range is kept identical to
`common/bindings/package.json`'s so npm hoists one copy: two copies of a SpacetimeDB SDK in one
process would mean two module registries and two sets of `DbConnection`/row-class identities.

## Warning

- **The offline BSATN assets have to actually be checked out.** `src/game-data/recipes.ts` reads
`crafting_recipe_desc.bsatn` straight off disk (default: `frontend/public/bsatn/static/`, see
`BRICO_BOT_GAME_DATA_DIR`), so a checkout of this repo without the frontend's static assets — or a
standalone deploy of just this package — needs that variable pointed at a real copy. Missing or
unreadable is a startup-time fatal error, deliberately: every craft's effort/skill/tier/item is
derived from this table, so starting the bridge without it would silently misclassify every craft
as effort-0 and item-less rather than failing loudly.
Loading
Loading