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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ updates:
nuget:
patterns: ['*']

# The EBS Cloudflare Worker (ebs-worker/).
- package-ecosystem: npm
directory: /ebs-worker
schedule:
interval: weekly
groups:
npm:
patterns: ['*']

# The EBS container's base images (mcr.microsoft.com/dotnet/sdk + aspnet).
- package-ecosystem: docker
directory: /src/EDNexus.Ebs
Expand Down
115 changes: 115 additions & 0 deletions .github/workflows/ebs-worker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: EBS Worker

# Tests the Twitch Extension Backend Service Cloudflare Worker (ebs-worker/) on every change and deploys
# it from main.
#
# Deploying needs three repository secrets (Settings > Secrets and variables > Actions):
# CLOUDFLARE_API_TOKEN API token allowed to edit Workers scripts and D1 databases
# CLOUDFLARE_ACCOUNT_ID your Cloudflare account ID
# CLOUDFLARE_D1_DATABASE_ID the database_id printed by `wrangler d1 create ednexus-ebs`
#
# The IDs are kept as secrets on purpose: this repository is public and ebs-worker/wrangler.jsonc is
# gitignored, so this workflow builds it from ebs-worker/wrangler.example.jsonc at deploy time. A fork
# without these secrets still runs the tests and simply skips the deploy. The Worker's runtime secrets
# (TWITCH_CLIENT_SECRET, ...) are set once with `wrangler secret put` and persist across deploys, so they
# are not handled here. See ebs-worker/README.md.

on:
push:
branches: [main]
paths:
- 'ebs-worker/**'
- '.github/workflows/ebs-worker.yml'
pull_request:
paths:
- 'ebs-worker/**'
- '.github/workflows/ebs-worker.yml'
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: ebs-worker

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: ebs-worker/package-lock.json

- run: npm ci
- run: npm run typecheck
- run: npm test

# Proves the Worker bundles and the config is valid, without touching Cloudflare. The placeholder
# database id is fine because a dry run never contacts the account.
- name: Bundle (dry run)
run: |
cp wrangler.example.jsonc wrangler.jsonc
npx wrangler deploy --dry-run --outdir "$RUNNER_TEMP/ebs-worker-dry-run"

deploy:
needs: test
# Pull requests only test. Deploys come from main, or when someone runs the workflow by hand on main.
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
# Never run two deploys (or migrations) at once, and let a running one finish instead of cancelling it.
concurrency:
group: ebs-worker-deploy
cancel-in-progress: false
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }}
steps:
- name: Check Cloudflare secrets are configured
id: cf
shell: bash
# Runs before checkout, so the default working directory (ebs-worker/) does not exist yet.
working-directory: .
run: |
if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ] && [ -n "$D1_DATABASE_ID" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::notice::CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_D1_DATABASE_ID is not set, so the deploy was skipped."
fi

- if: steps.cf.outputs.configured == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- if: steps.cf.outputs.configured == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: ebs-worker/package-lock.json

- name: Install dependencies
if: steps.cf.outputs.configured == 'true'
run: npm ci

- name: Create wrangler.jsonc from the template
if: steps.cf.outputs.configured == 'true'
run: sed "s/REPLACE_WITH_YOUR_D1_DATABASE_ID/${D1_DATABASE_ID}/" wrangler.example.jsonc > wrangler.jsonc

# Migrations go first so the new code never runs against an older schema.
- name: Apply D1 migrations
if: steps.cf.outputs.configured == 'true'
run: npx wrangler d1 migrations apply ednexus-ebs --remote

- name: Deploy Worker
if: steps.cf.outputs.configured == 'true'
run: npx wrangler deploy
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ Journal.*.log + *.json ──► JournalWatcher ──► JournalEventBus
| `src/EDNexus.Core` | Engine + feature services. No UI dependencies. |
| `src/EDNexus.App` | Avalonia 12 desktop UI (MVVM via CommunityToolkit.Mvvm). |
| `src/EDNexus.Cli` | Headless harness for validation. |
| `src/EDNexus.Ebs` | Twitch Extension Backend Service: relays commander state to viewers. |
| `src/EDNexus.Ebs` | Twitch Extension Backend Service (ASP.NET): relays commander state to viewers. Being replaced by `ebs-worker/`; keep both in step until the cutover is done and this project is removed. |
| `ebs-worker/` | The same EBS as a Cloudflare Worker (Hono + TypeScript + D1). Same HTTP contract. `cd ebs-worker && npm test`; see its README. Uses Wrangler (it has a Wrangler config). |
| `extension/` | The Twitch extension frontend (vanilla HTML/CSS/JS, no build step). |
| `src/EDNexus.Plugins.Abstractions`, `src/EDNexus.Plugins.Hosting` | Plugin SDK contract (plugins reference only this) and the plugin loader/installer/bridge. |
| `samples/HelloCommander` | Sample plugin + template. Its build assembles `out/<id>/` (git-ignored); `tests/EDNexus.Samples.Tests` loads it through the real host. |
Expand Down
13 changes: 13 additions & 0 deletions ebs-worker/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copy to .dev.vars (git-ignored) for `wrangler dev`. Never put real secrets in this example file.
# Restart `wrangler dev` after editing .dev.vars.
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_EXTENSION_ID=
# base64, the Extension Secret from the Twitch Developer Console (at least 16 bytes once decoded)
TWITCH_EXTENSION_SECRET=
# base64 of 32 random bytes. Encrypts the Twitch tokens stored in D1:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
TOKEN_ENCRYPTION_KEY=
TWITCH_OAUTH_REDIRECT_URI=http://localhost:8787/oauth/callback
# TWITCH_OWNER_USER_ID=
# ADDITIONAL_ALLOWED_FRONTEND_ORIGINS=https://localhost:8080
6 changes: 6 additions & 0 deletions ebs-worker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
.wrangler/
# The real config holds the id of YOUR D1 database; commit only wrangler.example.jsonc.
wrangler.jsonc
# Local secrets for `wrangler dev`.
.dev.vars
132 changes: 132 additions & 0 deletions ebs-worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# ebs-worker — the Twitch Extension Backend Service on Cloudflare Workers

A Cloudflare Worker (Hono + TypeScript + D1) that does what [`src/EDNexus.Ebs`](../src/EDNexus.Ebs/README.md)
does: it bridges the EDNexus desktop app and the Twitch extension frontend in [`extension/`](../extension/README.md).
The desktop app POSTs state here; the Worker verifies the caller and relays the update to viewers through
Twitch's Extensions PubSub API. It is also the desktop app's only path to Twitch login (the real OAuth 2.0
Authorization Code handshake with PKCE, issuing its own opaque token back to the desktop).

**The HTTP contract is unchanged**, so the desktop app and the extension need no change: same routes, status codes,
headers (`Retry-After`, `X-EDNexus-Pubsub-Rejected`, `X-EDNexus-Snapshot-Removed`), problem+json bodies, limits and
CORS policy. The API is documented in the [.NET README](../src/EDNexus.Ebs/README.md#api); the sections there on
*OAuth login flow*, *API*, *Rate limits* and *Privacy* apply as written. This README covers what is different.

## What changed from the ASP.NET service

| ASP.NET service | Worker |
|---|---|
| SQLite file `ebs.db` on a volume | D1 database `ednexus-ebs` (`migrations/0001_init.sql`) |
| Data Protection key ring on a separate volume | AES-GCM with the `TOKEN_ENCRYPTION_KEY` secret (also kept apart from the database) |
| Pending OAuth sessions and one-time codes in process memory | The `oauth_pending` table (short-lived; code payloads are encrypted, since they carry Twitch tokens) |
| Three `BackgroundService`s | Cron Triggers: prune (15 min), token refresh (30 min), credential check (6 h) |
| Per-channel `ChannelLocks` | None (see [Concurrency](#concurrency)) |
| ASP.NET rate limiter, `X-Forwarded-For` from a trusted proxy | Rate Limiting bindings for per-IP limits, a D1 counter for per-channel limits; client address is `CF-Connecting-IP` |
| Refuses to start on bad configuration | Answers `503 {"status":"misconfigured"}` and logs every problem (`wrangler tail`) |
| Docker image + Caddy | `wrangler deploy` |

Sessions or broadcaster tokens in the old database are **not** migrated (they are encrypted with a Data Protection
key ring that does not exist here). Every broadcaster signs in again once; the desktop app prompts for it on the
first `401`. Published cards reappear within the desktop's 10-minute heartbeat.

## Setup

```sh
cd ebs-worker
npm install
cp wrangler.example.jsonc wrangler.jsonc # gitignored: it holds YOUR D1 database id
npx wrangler d1 create ednexus-ebs # paste the database_id it prints into wrangler.jsonc
npm run db:migrate:remote
```

The repository is public, so nothing that identifies a Cloudflare account is committed: the D1 id lives only in your
local `wrangler.jsonc` and in the `CLOUDFLARE_D1_DATABASE_ID` GitHub secret.

### Secrets

Set each with `npx wrangler secret put NAME` (a masked prompt; never pass one as an argument):

| Secret | What it is |
|---|---|
| `TWITCH_CLIENT_ID` | The Twitch application that performs the OAuth login. |
| `TWITCH_CLIENT_SECRET` | Its client secret. |
| `TWITCH_EXTENSION_ID` | The extension's Client ID, sent as the `Client-Id` of the PubSub call. Often the same value as `TWITCH_CLIENT_ID`. |
| `TWITCH_EXTENSION_SECRET` | The extension's base64 secret from the Developer Console (≥ 16 bytes decoded; Twitch issues 32). Signs the PubSub JWT. |
| `TOKEN_ENCRYPTION_KEY` | **New.** base64 of 32 random bytes: `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`. Encrypts the Twitch tokens in D1. Losing or changing it logs every broadcaster out (they sign in again); the Worker keeps running. |

`npx wrangler secret list` prints names only, which makes it safe to audit against `src/env.ts`.

### Variables

In `wrangler.jsonc` under `vars` (all optional apart from the redirect URI, which has a default):

| Variable | Default | |
|---|---|---|
| `TWITCH_OAUTH_REDIRECT_URI` | `https://ednexus.signal-and-thread.com/oauth/callback` | Exactly as registered on the Twitch application. `http` is accepted for `localhost` only. |
| `TWITCH_OAUTH_SCOPES` | `user:read:email` | Space- or comma-separated; duplicates are dropped. |
| `TWITCH_OWNER_USER_ID` | unset | Numeric user id of the extension's owner, the `user_id` claim of the PubSub JWT. Set it if publishing fails with 401/403. |
| `MAX_STATE_PAYLOAD_BYTES` | `5000` | Compact state size forwarded to PubSub. Above `5120` (Twitch's hard limit) is refused. |
| `MAX_REQUEST_BODY_BYTES` | `16384` | Largest body `/api/update-state` and `/oauth/*` read; larger gets `413`. |
| `UPDATE_STATE_RATE_LIMIT` / `UPDATE_STATE_RATE_LIMIT_WINDOW_SECONDS` | `1` / `2` | Per authenticated channel. |
| `INITIAL_STATE_CACHE_SECONDS` | `5` | `max-age` of the public `Cache-Control` on a served snapshot; `0` disables it. |
| `OAUTH_SESSION_TTL_MINUTES` / `OAUTH_CODE_TTL_SECONDS` | `10` / `60` | |
| `TWITCH_TOKEN_REFRESH_BUFFER_MINUTES` | `60` | How far ahead of expiry a grant is refreshed. |
| `CHANNEL_STATE_MAX_AGE_HOURS` | `24` | Oldest snapshot served; `0` disables it, `1`–`11` is refused. See the [privacy note](../src/EDNexus.Ebs/README.md#privacy-the-24-hour-snapshot-window). |
| `ADDITIONAL_ALLOWED_FRONTEND_ORIGINS` | unset | Extra origins that may read `initial-state` from a browser (Developer Rig). **Leave unset in production.** |

The per-IP limits (`GET /api/initial-state`: 60 per 10 s; `/oauth/*`: 30 per 60 s, shared) are set on the
`ratelimits` bindings in `wrangler.jsonc`, not by variables: the Rate Limiting binding supports a 10 s or 60 s period
only, which is what both limits use.

### Local development

```sh
cp .dev.vars.example .dev.vars # fill in test values; git-ignored; restart `wrangler dev` after editing it
npm run db:migrate:local
npm run dev # http://localhost:8787
npm test # vitest, no Cloudflare account needed
npm run typecheck
```

The tests run the Worker's real SQL against SQLite (`node:sqlite`) loaded with the real migration, with Twitch
stubbed at `fetch`. They cover the OAuth flow, authentication, validation, limits, the credential check, token
refresh and the cron jobs.

## Deploying

CI (`.github/workflows/ebs-worker.yml`) tests every change to `ebs-worker/`, and on `main` applies migrations then
deploys, **only when** the repository has the secrets `CLOUDFLARE_API_TOKEN` (Account → Workers Scripts: Edit and
D1: Edit), `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_D1_DATABASE_ID`. Without them it tests and skips the deploy. Or by
hand: `npm run db:migrate:remote && npm run deploy`.

### Cutover from the container

1. Create the D1 database, apply the migration, set the five secrets, deploy.
2. `curl https://<workers.dev host>/healthz` → `{"status":"ok","pubsub":"unknown",...}`; an unsigned
`POST /api/update-state` → `401`; `GET /api/initial-state/1` → `404`.
3. Attach the custom domain in the dashboard (Worker → Settings → Domains & Routes → Add → Custom Domain) once the
container no longer owns the hostname. The registered OAuth redirect URI already points at it, so nothing changes
on the Twitch side; if you test on `*.workers.dev` first, that redirect URI must be registered too.
4. Run the [publish smoke test](../src/EDNexus.Ebs/README.md#publish-smoke-test) from the desktop app, and watch
`npx wrangler tail`. Within 6 hours (or run the cron by hand) `/healthz` reports `pubsub: accepted`.
5. Only then stop the container and remove `src/EDNexus.Ebs`, its tests, `ebs-docker.yml` and the Docker files.

## Behaviour notes

### Concurrency

A Worker has no process to hold a per-channel lock, so a publish and a sign-out can interleave. The invariant
(*no snapshot outlives its credential*) is kept by ordering instead: a publish re-checks its token before storing and
again afterwards, and takes the card back down if the token has gone; a sign-out clears, deletes the token, then
removes the snapshot once more. Every interleaving of the two ends with no snapshot and no live credential. The
residual cost is that viewers watching during that sub-second window can see the card flash on and then be sent
`offline` again. If that ever matters, a per-channel Durable Object is the way to get real serialisation.

### Limits worth knowing

- **Token refresh** handles at most 40 grants per 30-minute run (one Twitch subrequest each; the free plan caps a
run at 50). The soonest-expiring go first and the buffer is an hour, so a backlog drains before anything expires.
- **`pubsubCheckedAt`** in `/healthz` is when the PubSub verdict last *changed*, not when it was last checked (an
unchanged verdict is not rewritten, to save a D1 write on every publish).
- **The credential check** runs every 6 hours; there is no 10-second-after-boot check because a Worker has no boot.
`pubsub` is `unknown` until the first cron or the first real publish.
- Chunked uploads that declare no `Content-Length` are cut off when read, not as they stream.
49 changes: 49 additions & 0 deletions ebs-worker/migrations/0001_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- EDNexus EBS schema (ported from the SQLite file the ASP.NET service kept in ebs.db).
-- Timestamps are fixed-width ISO-8601 UTC text (Date.toISOString), so they compare correctly as text.

-- Long-lived broadcaster tokens and the Twitch grants they wrap. The EBS bearer token is stored only
-- as a SHA-256 hash; the Twitch access/refresh tokens are AES-GCM ciphertext (see src/crypto.ts).
CREATE TABLE broadcaster_tokens (
channel_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
twitch_access_token TEXT NOT NULL,
twitch_refresh_token TEXT NOT NULL,
twitch_expires_at TEXT NOT NULL,
is_twitch_grant_valid INTEGER NOT NULL,
created_at TEXT NOT NULL,
last_refreshed_at TEXT NULL
);

-- The last state each channel published, served by GET /api/initial-state/{channelId}.
CREATE TABLE channel_state (
channel_id TEXT PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX ix_channel_state_updated_at ON channel_state (updated_at);

-- Pending /oauth/authorize sessions (kind 'session') and one-time authorization codes (kind 'code').
-- The .NET service held these in process memory; Worker isolates share none, so they live here, short
-- lived. A 'code' payload carries Twitch tokens and is AES-GCM ciphertext, never plaintext.
CREATE TABLE oauth_pending (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX ix_oauth_pending_expires_at ON oauth_pending (expires_at);

-- Fixed-window counters for the per-channel limits (update-state, clear-state).
CREATE TABLE rate_limit (
key TEXT PRIMARY KEY,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL
);

-- Small key/value status rows (whether Twitch accepts the PubSub credentials).
CREATE TABLE service_status (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Loading
Loading