From 7067f92d92f201cc3681ce778a16dbaa1bbd2096 Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 3 May 2026 21:15:21 -0400 Subject: [PATCH 1/4] docs: Discord linked roles spec and execution plan Add docs/DISCORD_LINKED_ROLES.md and LINKED_ROLES_EXECUTION.md; README links. Website PR should merge before enabling Hermes producer. Branch: feat/discord-linked-roles Co-authored-by: Cursor --- README.md | 1 + docs/DISCORD_LINKED_ROLES.md | 428 +++++++++++++++++++++++++++++++++ docs/LINKED_ROLES_EXECUTION.md | 80 ++++++ 3 files changed, 509 insertions(+) create mode 100644 docs/DISCORD_LINKED_ROLES.md create mode 100644 docs/LINKED_ROLES_EXECUTION.md diff --git a/README.md b/README.md index ff7e7152..e78df36f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ TBD - CDN: AWS S3, Cloudflare - ORM: Prisma - Database Provider: Turso +- Discord Linked Roles: [spec + ship checklist](./docs/DISCORD_LINKED_ROLES.md) · [execution plan (PR order)](./docs/LINKED_ROLES_EXECUTION.md) # Developer Guidelines diff --git a/docs/DISCORD_LINKED_ROLES.md b/docs/DISCORD_LINKED_ROLES.md new file mode 100644 index 00000000..58a05609 --- /dev/null +++ b/docs/DISCORD_LINKED_ROLES.md @@ -0,0 +1,428 @@ +# Discord Linked Roles — PRD, evaluation, and architecture (Option A) + +Cross-repo plan. **Parts I–VI** = requirements and architecture; **Part VII** = **v1 ship spec** (enough detail to implement and deploy in one pass). Update Part VII when reality diverges. + +**Branches & PR order:** [LINKED_ROLES_EXECUTION.md](./LINKED_ROLES_EXECUTION.md). + +--- + +## Part I — Product requirements (PRD) + +### Problem + +Players want Discord servers to grant roles from RaidHub-visible criteria (e.g. clears). Admins use Discord Linked Roles. RaidHub already separates **web auth** (Bungie + linked providers) from **raid data** (Postgres / services). + +### Actors + +- **Player** — link Discord, understand sync status, recover when broken. +- **Guild admin** — rules match documented metadata; roles update after criteria change. +- **Operators** — tokens safe, costs bounded, observable failures. + +### Functional requirements + +| ID | Requirement | +|----|----------------| +| FR-01 | Signed-in user can **connect** Discord to their RaidHub (Bungie) identity. | +| FR-02 | User can **disconnect** or **replace** linked Discord; effect on Discord roles follows Discord product rules. | +| FR-03 | **Consent** for scopes needed for linked roles (copy + legal outside this doc). | +| FR-04 | When **authoritative** RaidHub stats that feed linked-role metadata **change**, that user’s Discord application role connection metadata is **updated** so Discord can re-evaluate (within **NFR-08** SLO). | +| FR-04a | **Authoritative moment (clears path):** For stats updated in the **same Postgres transaction** as new instance storage (e.g. `player_stats` / `player` clears in `lib/services/instance_storage/instance.go`), metadata used for those fields **MUST** be computed **after** that transaction commits. **Primary sync trigger:** post-commit on new instance (debounced per user — Part III). | +| FR-04b | **Deferred metrics:** Any metadata field sourced from data **not** guaranteed at instance commit (e.g. future fields tied only to `player_crawl` side effects) **MUST** declare a **secondary trigger** (e.g. post-crawl queue) or accept higher staleness in the product matrix. | +| FR-05 | Metadata schema can **evolve** within Discord’s linked-role constraints. | +| FR-06 | User-visible **sync health**: linked or not, and whether RaidHub recently **successfully** pushed metadata (or “needs reconnect” / “pending”). | +| FR-07 | On Discord auth failure, user has a **clear reconnect** path without losing Bungie account. | +| FR-08 | No presenting eligibility RaidHub cannot justify from last successful push + known stats rules (product copy balances honesty vs Discord lag). | +| FR-09 | Admins see metadata fields **consistent** with RaidHub documentation. | +| FR-10 | Only **intended** metadata keys go to Discord linked roles. | + +### Non-functional requirements + +| ID | Requirement | +|----|----------------| +| NFR-01 | **Source of truth** for auth and **Discord ↔ Bungie** linkage + OAuth tokens: **RaidHub-Website DB** (Turso + Prisma / NextAuth). | +| NFR-02 | **Source of truth** for raid clears and gameplay aggregates: **RaidHub-Services Postgres** (and related stores), not Turso. | +| NFR-03 | OAuth secrets: least privilege, no logging of tokens, rotation story for operators. | +| NFR-04 | Linking/login UX **does not** hard-depend on worker health; **staleness** may increase if workers fail (see NFR-08). | +| NFR-05 | Discord + internal **rate limits**: coalesce/debounce; caps per time window. | +| NFR-06 | Metrics: attempts, success, failure class, latency; avoid high-cardinality PII in labels. | +| NFR-07 | Deletion / unlink: retention and cleanup align with policies (existing Prisma relations). | +| NFR-08 | **Eventual consistency** with explicit **max staleness SLO** (product sets numeric target). | +| NFR-09 | **Single writer** for OAuth token **refresh** unless an ADR introduces a second writer with merge rules. | +| NFR-10 | No second **authoritative** auth DB; read replicas / projections are non-authoritative. | + +--- + +## Part II — PRD evaluation (self-check) + +| Area | Assessment | +|------|------------| +| FR-04 split | **Fixed:** FR-04a/04b remove ambiguity between “instance committed” vs “async crawl” using **code-verified** behavior: many clear counters update **pre-commit** in `instance_storage.Store` → **post-commit enqueue** is sufficient for those fields. | +| FR-06 | Still needs **one** chosen persistence (Part III §6). | +| FR-08 vs NFR-08 | Product must set **SLO** numeric target; engineering implements debounce + queue lag budgets under it. | +| NFR-09 | **Critical:** worker must not silently fork token refresh without BFF coordination. | + +--- + +## Part III — Architecture (Option A): workers read Turso + +### Goals + +- **Writes:** NextAuth / BFF only for `account` token rows and linkage (NFR-01, NFR-09 default). +- **Reads:** Go workers (Hermes-managed queue) read Turso with a **dedicated credential** to resolve Discord OAuth + map **Destiny `membershipId`** (from instances) → **Bungie id** → `account` (`provider = discord`). +- **Stats:** Read from Postgres after commit (NFR-02); same transaction already updates many aggregates used for clears-style metadata (**FR-04a**). + +### Context diagram + +```mermaid +flowchart LR + subgraph bff["RaidHub-Website"] + NA[NextAuth] + end + Turso[("Turso SoT auth/link/tokens")] + NA --> Turso + + subgraph ingest["RaidHub-Services"] + IS[instance_store] + PG[(Postgres)] + RMQ[RabbitMQ] + WR[discord_metadata worker] + Redis[(Redis)] + end + IS --> PG + IS --> RMQ + RMQ --> WR + WR --> Redis + WR --> Turso + WR --> PG + WR --> Discord[Discord API role connection] +``` + +### Sequence (happy path) + +```mermaid +sequenceDiagram + participant IS as instance_store + participant PG as Postgres + participant RMQ as RabbitMQ + participant W as discord_metadata worker + participant TS as Turso read + participant D as Discord + + IS->>PG: Store + updatePlayerStats in txn, commit + IS->>RMQ: enqueue sync candidates (per player debounce key) + Note over W: Consumer coalesces by destiny/bungie id + W->>TS: read discord account row + alt not linked + W-->>W: no-op + metric + else linked + W->>PG: read aggregates for metadata fields + W->>D: PATCH role connection Bearer=user token + W->>TS: optional scoped write sync timestamp + end +``` + +### Identity resolution (repo-accurate) + +- **Instance DTO** (`RaidHub-Services/lib/dto/instance.go`): players carry **Destiny** `membershipId` / `membershipType` in `PlayerInfo`. +- **Turso** (`RaidHub-Website/prisma/schema.prisma`): `destiny_profile.destiny_membership_id` → `bungie_membership_id` → `account` row for Discord. +- Resolver: **Destiny membership id (+ type if needed)** → SQL against Turso → Discord `providerAccountId` + tokens. + +### Hermes / messaging + +- **Queue name (v1):** `discord_role_metadata_sync` — constant in `RaidHub-Services/lib/messaging/routing/constants.go`, worker file `lib/messaging/queue-workers/discord_role_metadata_sync.go` (or equivalent), register in `RaidHub-Services/apps/hermes/main.go` next to other topics. +- **Producer:** `RaidHub-Services/lib/services/instance_storage/orchestrator.go` — after successful `tx.Commit()` and **only when** `instanceIsNew` (same gate as `InstanceParticipantRefresh` today), publish one message per **distinct** `inst.Players[i].Player.MembershipId` (Destiny membership id, int64). **Payload shape — see Part VII §3.** +- **Debounce:** Redis — see Part VII §4. + +### Turso credentials + +| Token type | Purpose | +|------------|---------| +| Read (SELECT on `account`, `destiny_profile`, `bungie_user`) | Resolve linkage + read `access_token` / `refresh_token` / `expires_at` / `scope`. | +| Optional narrow write | **Only** if FR-06 chooses worker-updated columns (see §6); limit to `UPDATE ... SET discord_metadata_synced_at`, etc. Never arbitrary OAuth writes from Go without ADR. | + +### Discord OAuth (Website) gaps today + +- `RaidHub-Website/src/lib/server/auth/index.ts`: Discord authorize URL uses **`scope=identify` only** — linked roles need **`role_connections.write`** (and retain `identify` for linking). **Re-link** required for existing Discord-linked users after scope change (**FR-03**). + +### Keeping Discord OAuth up to date (NFR-03, NFR-09, FR-07) + +**Current codebase behavior** + +- Discord tokens are stored on **link** via `PrismaAdapter.linkAccount` (`account.access_token`, `refresh_token`, `expires_at`, `scope`). +- **Bungie** tokens are proactively refreshed in `sessionCallback` (`refreshBungieAuth`); **RaidHub** JWT is refreshed there too. There is **no** equivalent **Discord** refresh on session load today — Turso can hold an **expired** Discord `access_token` until the user re-authenticates with Discord or something else updates the row. + +**What “up to date” must cover** + +1. **Access token** — short-lived; must be refreshed before calling Discord APIs (linked-role metadata PATCH, or any `@me` call). +2. **Refresh token** — Discord may rotate it on refresh; persist the **new** refresh token whenever Discord returns one. +3. **Scopes** — adding `role_connections.write` requires **re-consent**; old rows are insufficient until the user completes OAuth again (**FR-03**). +4. **Revocation** — user disconnects app in Discord or RaidHub unlinks; Turso row removed/updated; workers must **no-op** gracefully. + +**Recommended implementation (BFF as single refresh writer)** + +| Mechanism | Role | +|-----------|------| +| **Session-time refresh** | Extend server session path (same idea as `refreshBungieAuth` in `sessionCallback.ts`): if user has `account` where `provider = discord` and access token missing or `expires_at` within a **skew buffer** (e.g. 5 minutes), call Discord `POST https://discord.com/api/oauth2/token` with `grant_type=refresh_token`, then **`prisma.account.update`** for that provider row. Runs whenever an authenticated session is loaded — keeps Turso warm for users who use the site. | +| **On-demand refresh** | Before BFF-only actions (“Sync linked roles” button) or before any BFF-initiated Discord user API call, run the same helper if near expiry. | +| **Adapter / getUser** | Ensure any code path that loads the user for session includes enough `accounts` data to decide if Discord refresh is needed (today `getUser` / session paths are Bungie-centric in places — implementation must load the Discord `account` row when implementing refresh). | +| **Worker (Hermes)** | v1: **read** token from Turso, call Discord; on **401** / invalid grant → metrics + surface **reconnect** (FR-06/07); **do not** refresh from Go unless a future ADR adds a **single** Turso write path for token rotation. | +| **Optional safety net** | Scheduled job (e.g. daily) calling the **same** refresh helper for users with linked Discord who are “due” — low frequency, bounded batch, same BFF-owned code to avoid splitting refresh logic. Only if session-only refresh leaves too many stale tokens for **inactive** users who never open the site. | + +**Why this matters for linked roles** + +Background workers can run **minutes after** a raid commit while the player is **not** on the website. If Discord `access_token` is already expired and nothing has refreshed it, the worker’s PATCH fails until the user hits the site (session refresh) or you add worker refresh + write-back (ADR). Tight **access token TTL + session refresh** minimizes that gap; optional **scheduled BFF refresh** narrows it for inactive users if product requires. + +### Token refresh policy summary (NFR-09) + +- **Default:** only **BFF** refreshes Discord OAuth and **writes** Turso `account` rows. +- **Worker:** use stored access token; on hard auth failure, **no** Go refresh in v1; user reconnect flow. +- **Optional ADR:** worker refresh with one controlled Turso `UPDATE` for tokens only. + +### Part IV — Traceability (plan vs PRD) + +| PRD | Plan coverage | +|-----|----------------| +| FR-01–03, 07 | Website OAuth + scopes; reconnect UX. | +| FR-04 + 04a | Post-commit queue from `orchestrator.go`; stats from committed Postgres. | +| FR-04b | Explicit per-field trigger table when new metadata added. | +| FR-05–10 | Metadata module + Discord app schema versioning + docs site. | +| NFR-01–02 | Turso read + Postgres reads; no duplicate SoT. | +| NFR-03–07 | Read token, metrics, no token logs, Redis debounce. | +| NFR-08–10 | SLO TBD product; single refresh writer default. | + +### Part V — Codebase alignment checklist + +| Component | Today | Plan touch | +|-----------|--------|------------| +| `RaidHub-Website` … `auth/index.ts` | Discord `identify` only | Add `role_connections.write`. | +| `RaidHub-Website` … `sessionCallback.ts` | Refreshes Bungie + RaidHub JWT only | Add **Discord** access-token refresh (mirror Bungie pattern) so Turso stays valid for workers. | +| `RaidHub-Website` … `prisma/schema` | `account` holds tokens | Optional columns for FR-06. | +| `RaidHub-Services` … `orchestrator.go` | Publishes subscription stage 1 | Also publish discord sync intent. | +| `RaidHub-Services` … `routing/constants.go` | No discord metadata queue | Add constant + worker. | +| `RaidHub-Services` … Hermes | Registers topics | Register new topic. | +| Go | No Turso | New `lib/database/turso` or similar + env. | +| `RaidHub-API` | User JWT + Discord **invocation** JWT | Unchanged for linked roles v1 (different concern than Turso user OAuth). | +| `raidhub-discord` Python | Slash commands → API | Unchanged for linked roles v1. | + +### Part VI — Deferred (post-v1 or product-owned) + +- **NFR-08 numeric SLO** — set in monitoring runbooks once traffic is observed (Part VII gives interim targets). +- **Worker-side OAuth refresh** + Turso token write-back — only via ADR if session + optional cron are insufficient. +- **Metadata fields** tied exclusively to `player_crawl` — add **FR-04b** second consumer when those fields ship. + +--- + +## Part VII — v1 ship spec (compact execution) + +Use this section as the **single implementation brief**. Assumes Option A (Hermes worker reads Turso + Postgres, pushes Discord). + +**Cross-repo review (automated + schema audit, 2026-05-03):** Prisma `Account.userId` maps to SQL **`account.bungie_membership_id`** (join fix in §5). `core.player.membership_id` is **`BIGINT`**. Hermes declares queues in **`apps/hermes/topic_manager.go`**, not `lib/messaging/processing`. NextAuth provider id remains **`discord`**. + +### 1) Locked v1 decisions (no bikeshedding for first ship) + +| Topic | v1 choice | +|-------|-----------| +| FR-06 | **Metrics-first:** Prometheus counters/histograms on worker + BFF refresh failures; **no** new Prisma columns required for first prod ship. Add Turso `account` sync columns in v1.1 if UX needs “last synced” in-app. | +| Queue payload | **One Rabbit message per affected Destiny `membership_id`** per new instance (dedupe distinct players in producer). | +| Debounce TTL | **300 seconds** per Destiny membership id (Redis). | +| Worker token refresh | **No** — BFF-only refresh; worker on 401 → metric + stop. | +| Feature gate | **Env** `DISCORD_LINKED_ROLES_ENABLED` (Go): when `false` / `0` / unset, **producer** in `orchestrator.go` **must not** publish (no queue backlog). **Hermes** still registers the topic so deploys are uniform; worker **first line** may also no-op when disabled to drain any in-flight messages after a flag-off rollback. Document in `RaidHub-Services/example.env`. | +| Interim SLO | Target **P95** queue wait + processing **< 15 minutes** under normal load; tune after metrics. | + +### 2) Discord Developer Portal (before code merge) + +1. Same **Discord Application** as production OAuth client used by `RaidHub-Website` (`DISCORD_CLIENT_ID`). +2. **Linked roles** → configure **Application Role Connection Metadata** (field keys you will send in JSON `metadata` map — use **snake_case** keys matching Discord schema, values **strings** per Discord API). +3. Note **Application ID** (often equals client id) for URL path — store as `DISCORD_APPLICATION_ID` in worker env (verify in portal if differ). +4. OAuth2 redirect URLs unchanged unless you add routes. +5. After scope change, communicate **“Reconnect Discord”** for existing linked users. + +**References (official):** + +- [Configuring app metadata for linked roles](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles) +- [Application Role Connection Metadata object](https://discord.com/developers/docs/resources/application-role-connection-metadata) +- [Update Current User Application Role Connection](https://discord.com/developers/docs/resources/user#update-current-user-application-role-connection) — **`PUT`** `https://discord.com/api/v10/users/@me/applications/{application.id}/role-connection` (requires OAuth2 access token with **`role_connections.write`** for that `application.id`). + +### 3) Rabbit message schema (v1) + +**Queue:** `discord_role_metadata_sync` + +**Body (JSON):** + +```json +{ + "schemaVersion": 1, + "trigger": "instance_new", + "destinyMembershipId": "12345678901234567890", + "instanceId": 16787546313 +} +``` + +| Field | Type | Notes | +|-------|------|--------| +| `schemaVersion` | int | Bump when payload incompatible. | +| `trigger` | string | v1: always `instance_new`. | +| `destinyMembershipId` | string | Decimal string of `PlayerInfo.MembershipId` from `dto.Instance` (JSON marshals int64; consumer accepts string for bigint safety). | +| `instanceId` | int64 | For logs/metrics correlation only; worker may ignore for metadata computation. | + +**Producer:** `orchestrator.go` after commit, inside `if instanceIsNew { ... }`, loop `inst.Players`, build `set` of `MembershipId`, for each publish one message. **Guard** with `DISCORD_LINKED_ROLES_ENABLED`. + +### 4) Redis debounce (v1) + +- **Key:** `discord_lr:debounce:{destinyMembershipId}` (string id). +- **Op:** `SET key 1 NX EX 300` — if `SET` fails (key exists), consumer **acks without work** (metric: `discord_lr_debounce_skip_total`). +- **Where:** Hermes worker process (same Redis singleton as clan cache — `RaidHub-Services/lib/database/redis`). + +### 5) Turso read contract (v1) + +**Driver:** Go `database/sql` + Turso/libSQL official client (see [Turso Go SDK](https://docs.turso.tech/sdk/go/reference) at ship time; package/import path may change — pin version in `go.mod`). + +**Env (Services):** + +| Variable | Required | Purpose | +|----------|----------|---------| +| `TURSO_AUTH_DB_URL` | yes | `libsql://...` URL for **read** token (prefer read-only token from Turso dashboard). | +| `TURSO_AUTH_DB_TOKEN` | yes | Auth token for that URL. | + +**Resolve Discord row by Destiny membership id** (SQLite table names from Prisma `@@map`; Prisma field `Account.userId` → SQL column **`bungie_membership_id`**, not `user_id`): + +```sql +SELECT a.access_token, a.refresh_token, a.expires_at, a.scope, a.provider_account_id +FROM account AS a +INNER JOIN destiny_profile AS d + ON d.bungie_membership_id = a.bungie_membership_id +WHERE a.provider = 'discord' + AND d.destiny_membership_id = ? +LIMIT 1; +``` + +- `?` = string destiny id (matches `destiny_profile.destiny_membership_id` text). +- If **`destiny_profile.bungie_membership_id` is NULL** for that row, the join yields no account — treat as **unlinked** (same metric path as missing Discord account). +- If **no row:** increment `discord_lr_unlinked_total`, return (success no-op). +- **Never** log `access_token` / `refresh_token`. +- **`expires_at`:** stored as Unix **seconds** (Prisma `Int?`); compare with `time.Now().Unix()` in worker when deciding whether token is likely stale (still prefer BFF refresh policy; worker may proceed and rely on Discord HTTP status). + +### 6) Postgres reads for metadata (v1 minimal) + +Implement **one** metadata builder in the worker (same package pattern as other Postgres access — use existing `postgres.DB` / `search_path`). + +- **Table:** `core.player` — column `clears` (see `infrastructure/postgres/migrations/002_core_schema.sql` and `lib/services/instance_storage/instance.go` `UPDATE player`). +- **Join key:** `core.player.membership_id` (**`BIGINT`**, not `INTEGER`) = int64 parsed from `destinyMembershipId` message field. Application SQL elsewhere uses unqualified `player` and relies on DB **`search_path`** including `core` (`infrastructure/postgres/init/setup.sql`); qualified `core.player` is safest in new worker SQL. +- **v1 example metadata map:** `{ "": "" }` — `` must **exactly** match a key in Discord **Application Role Connection Metadata** (portal may type it as INTEGER; HTTP body still uses **string** values per [API](https://discord.com/developers/docs/resources/user#update-user-application-role-connection)). +- **Extension:** per-activity clears from `core.player_stats` when product registers more metadata fields (same worker, same txn as `core.player` read). + +### 7) Discord HTTP call (worker) + +- **Method:** `PUT` +- **URL:** `https://discord.com/api/v10/users/@me/applications/{DISCORD_APPLICATION_ID}/role-connection` +- **Header:** `Authorization: Bearer {access_token from Turso}` +- **Header:** `Content-Type: application/json` +- **Body (JSON params per Discord):** all keys optional, but linked roles need **`metadata`** populated. Minimum v1: `{ "platform_name": "RaidHub", "metadata": { "": "" } }`. Optional: `platform_username` (e.g. Bungie global name) — max 100 chars per API. **`platform_name`** max 50 chars. Keys in `metadata` must match **Application Role Connection Metadata** keys from the Developer Portal. + +**Errors:** + +| Condition | Action | +|-----------|--------| +| HTTP 401 / invalid OAuth | `discord_lr_discord_auth_fail_total`; do not retry body indefinitely — DLQ or limited retry per Hermes policy. | +| HTTP 429 | Respect `Retry-After`; Hermes retry should backoff. | +| 5xx | Retry with existing worker retry semantics. | + +### 8) Website (BFF) — required code paths + +| Step | File / area | Action | +|------|-------------|--------| +| W1 | `src/lib/server/auth/index.ts` | Discord authorize URL: scopes **`identify` + `role_connections.write`** (space-separated in `scope` query param). | +| W2 | `src/lib/server/auth/sessionCallback.ts` (+ small `discordRefresh.ts`) | Load Discord `account` for `user.id`; if `expires_at` null or within **300s** of expiry, `POST https://discord.com/api/oauth2/token` with `client_id`, `client_secret`, `grant_type=refresh_token`, `refresh_token`; update `account` access/refresh/expires. | +| W3 | Adapter `getUser` / session includes | Ensure session load can read Discord `account` fields needed for W2 (extend Prisma `include` where only Bungie `accounts` is loaded today — `adapter.ts` paths). | +| W4 | (Optional v1) | Server action “Sync Discord roles” calling same refresh helper then **same** `PUT` role-connection as worker **or** rely on worker only — product choice; if omitted, inactive users depend on session visits for token freshness. | + +**Website env:** reuse `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` for refresh endpoint. + +### 9) Observability (minimum viable) + +| Metric (Prometheus) | Type | Labels (low cardinality) | +|---------------------|------|----------------------------| +| `discord_lr_publish_total` | counter | `result` = ok \| fail | +| `discord_lr_work_total` | counter | `result` = ok \| debounce_skip \| unlinked \| discord_4xx \| discord_5xx \| panic | +| `discord_lr_work_duration_seconds` | histogram | none or `result` ok only | + +Logs: always log `destinyMembershipId`, `instanceId`, `trigger`; never log tokens. + +### 10) Rollout checklist (prod order) + +1. Portal: metadata schema published. +2. Deploy **Website** with new scopes + refresh logic; monitor OAuth errors. +3. Run comms: existing users **Reconnect Discord**. +4. Create Turso **read** credential; store in secrets manager for **Hermes** (not in repo). +5. Set `DISCORD_APPLICATION_ID` + `TURSO_*` + `DISCORD_LINKED_ROLES_ENABLED=false` on workers. +6. Deploy **Services** binary with producer + worker + Redis debounce **disabled**. +7. Enable `DISCORD_LINKED_ROLES_ENABLED=true` on **canary** Hermes; watch metrics + Discord dev dashboard. +8. Full enable; set alert on `discord_lr_discord_auth_fail_total` rate. + +**Rollback:** set `DISCORD_LINKED_ROLES_ENABLED=false`; redeploy or hot-reload env; queue drains or DLQ clears per ops policy. + +### 11) Environment variables (copy checklist) + +**RaidHub-Services (Hermes / worker + producer)** + +| Variable | Example | Who sets | +|----------|---------|----------| +| `DISCORD_LINKED_ROLES_ENABLED` | `true` / `false` | ops | +| `DISCORD_APPLICATION_ID` | snowflake string | ops (Portal → Application ID) | +| `TURSO_AUTH_DB_URL` | `libsql://...` | ops (read-capable token) | +| `TURSO_AUTH_DB_TOKEN` | secret | ops | + +**RaidHub-Website (existing + behavior)** + +| Variable | Notes | +|----------|--------| +| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Already used for OAuth; refresh token POST reuses these. | + +### 12) Local / CI dev notes + +- **Website local DB** is file SQLite (`APP_ENV=local`) — no Turso unless pointed at branch; **worker integration** against Turso needs `TURSO_*` to a dev database or skip worker in CI. +- **Hermes** needs Redis + Rabbit + Postgres + Turso reachable from Docker network if worker runs in compose. +- Add **`example.env` entries** in `RaidHub-Services` for all new vars (copy-paste documented). + +### 13) Explicit non-goals (v1) + +- No changes to `RaidHub-API` user JWT, `raidhub-discord` Python bot, or subscription webhooks for linked roles. +- No Postgres table for Discord user id (Turso remains SoT). +- No worker-written OAuth tokens. + +### 14) One-page implementation order (for agents / humans) + +1. Portal: metadata keys + linked roles tutorial complete. +2. Website: scopes (`index.ts`). +3. Website: Discord refresh + adapter/session includes (`sessionCallback`, `adapter.ts`, small helper). +4. Services: `example.env` + register vars in `lib/env/env.go` (`getEnv` / `getEnvWithDefault` pattern — see existing `DISCORD_*` optional vars). +5. Services: `routing/constants.go` + message struct (same package as other `messages/*.go`). +6. Services: producer loop in `orchestrator.go` behind `DISCORD_LINKED_ROLES_ENABLED`. +7. Services: Turso client package + resolver SQL (§5). +8. Services: worker topic + Redis debounce (§3–4) + Postgres metadata (§6) + Discord PUT (§7). +9. Services: Hermes `main.go` register topic. +10. Metrics + dashboards (§9). +11. Staging e2e: link Discord → finish raid → observe PUT + role in test server. +12. Prod rollout (§10). + +### 15) RabbitMQ / Hermes wiring note + +Queues are **not** statically listed in `infrastructure/rabbitmq/definitions.json` (empty `queues` array). **Declaration:** `RaidHub-Services/apps/hermes/topic_manager.go` — durable queue named `TopicConfig.QueueName`, bound to delayed exchange `hermes.delayed` with routing key = queue name, then consume (same as all Hermes topics). + +**Registration:** append `qw.YourTopic()` to the `topics` slice in `apps/hermes/main.go` (see lines ~77–89 today). + +**TopicConfig:** defined in `lib/messaging/processing/topic.go`. For outbound Discord HTTP (no Bungie), mirror **`subscription_delivery.go`** (prefetch `1`, `KeepInReady: true`, higher `MaxRetryCount`, custom retry delay for 429). Do **not** add `BungieSystemDeps` unless the worker calls Bungie. + +**Publish:** `publishing.PublishJSONMessage(ctx, routing., payload)` — queue name string must match `routing` constant exactly. + +--- + +## Revision history + +| Date | Change | +|------|--------| +| 2026-05-03 | Initial consolidated PRD + Option A plan; FR-04a grounded in `instance_storage` transactional clears update. | +| 2026-05-03 | §Keeping Discord OAuth up to date: current gap vs session/on-demand/worker policy. | +| 2026-05-03 | **Part VII** v1 ship spec: locked defaults, schemas, SQL, APIs, env, rollout, non-goals. | +| 2026-05-03 | Part VII tightened: `core.player`, env table, impl order §14, Rabbit note §15. | +| 2026-05-03 | **Deep review:** fix Turso SQL join (`bungie_membership_id` not `user_id`); `BIGINT` + `search_path` note; Discord PUT + `role_connections.write`; Hermes `topic_manager.go`; lock feature-flag semantics; API body optional fields. | diff --git a/docs/LINKED_ROLES_EXECUTION.md b/docs/LINKED_ROLES_EXECUTION.md new file mode 100644 index 00000000..2c93a96a --- /dev/null +++ b/docs/LINKED_ROLES_EXECUTION.md @@ -0,0 +1,80 @@ +# Discord Linked Roles — execution plan (branches + PR order) + +**Branches (created locally):** + +| Repo | Branch | Base | +|------|--------|------| +| [RaidHub-Services](https://github.com/Raid-Hub/RaidHub-Services) | `feat/discord-linked-roles` | `main` | +| [RaidHub-Website](https://github.com/Raid-Hub/Web-App) | `feat/discord-linked-roles` | `main` | + +**Canonical spec (in each repo):** [`docs/DISCORD_LINKED_ROLES.md`](./DISCORD_LINKED_ROLES.md) (Part VII = implementable checklist). + +--- + +## PR strategy (recommended) + +Ship in **two PRs** so Website (scopes + refresh) can merge and soak **before** workers start pushing to Discord. + +### PR 1 — Website first (`Web-App` → `feat/discord-linked-roles`) + +**Goal:** Users re-consent `role_connections.write`; Turso tokens stay fresh on session. + +- [ ] Discord authorize URL: `identify` + `role_connections.write` (`src/lib/server/auth/index.ts`). +- [ ] Discord OAuth refresh on session path + `prisma.account.update` (`sessionCallback.ts`, helper; extend `adapter.ts` / `getUser` includes for Discord `account` row). +- [ ] Copy/link spec: `docs/DISCORD_LINKED_ROLES.md` + README pointer to `./docs/DISCORD_LINKED_ROLES.md`. +- [ ] Comms / settings copy: “Reconnect Discord” for existing linked users. + +**Merge when:** CI green; smoke test link + session on staging Turso. + +### PR 2 — Services second (`RaidHub-Services` → `feat/discord-linked-roles`) + +**Goal:** Post–new-instance, debounced push to Discord using Turso read + Postgres stats. + +- [ ] `DISCORD_LINKED_ROLES_ENABLED`, `DISCORD_APPLICATION_ID`, `TURSO_AUTH_DB_URL`, `TURSO_AUTH_DB_TOKEN` in `lib/env/env.go` + `example.env`. +- [ ] `routing` constant + message type + `publishing` from `orchestrator.go` (gated flag). +- [ ] Turso read client + §5 SQL; Postgres metadata read (`core.player`); Redis debounce §4. +- [ ] Hermes topic + `main.go` registration; Discord `PUT` §7; metrics §9. +- [ ] Docs: `docs/DISCORD_LINKED_ROLES.md`, `docs/ARCHITECTURE.md` see-also, this file. + +**Merge when:** CI green; staging Hermes reaches Turso + Discord test app; rollout §10 dry-run with flag off then on. + +### PR 3 — Optional / later + +- [ ] In-app “Sync linked roles” + FR-06 Turso columns (v1.1). +- [ ] Worker token refresh ADR (only if metrics show mass 401 for inactive users). + +--- + +## Dependency rule + +**Do not enable** `DISCORD_LINKED_ROLES_ENABLED=true` in production until **PR 1** is deployed and users can obtain new scopes (otherwise workers will 401). + +--- + +## Push branches + +```bash +# Services +cd RaidHub-Services && git push -u origin feat/discord-linked-roles + +# Website +cd RaidHub-Website && git push -u origin feat/discord-linked-roles +``` + +Open PRs with title prefix: `feat(linked-roles): …` — link cross-repo PRs in descriptions. + +--- + +## Stash recovery (if needed) + +```bash +# If you had other WIP on old branches: +cd RaidHub-Services && git stash list # pop onto correct feature branch if relevant +cd RaidHub-Website && git stash list +``` + +--- + +## Out of scope (same as spec) + +- `RaidHub-API`, `raidhub-discord`, `subscription-webhook-relay` — no branches required for v1. From 3f5766448bdf97633c8f4831c434f2bae56ec3ca Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 3 May 2026 21:15:50 -0400 Subject: [PATCH 2/4] docs: fix Services repo link (Raid-Hub/Services) Co-authored-by: Cursor --- docs/LINKED_ROLES_EXECUTION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LINKED_ROLES_EXECUTION.md b/docs/LINKED_ROLES_EXECUTION.md index 2c93a96a..5256bbd8 100644 --- a/docs/LINKED_ROLES_EXECUTION.md +++ b/docs/LINKED_ROLES_EXECUTION.md @@ -4,7 +4,7 @@ | Repo | Branch | Base | |------|--------|------| -| [RaidHub-Services](https://github.com/Raid-Hub/RaidHub-Services) | `feat/discord-linked-roles` | `main` | +| [Services](https://github.com/Raid-Hub/Services) | `feat/discord-linked-roles` | `main` | | [RaidHub-Website](https://github.com/Raid-Hub/Web-App) | `feat/discord-linked-roles` | `main` | **Canonical spec (in each repo):** [`docs/DISCORD_LINKED_ROLES.md`](./DISCORD_LINKED_ROLES.md) (Part VII = implementable checklist). @@ -26,7 +26,7 @@ Ship in **two PRs** so Website (scopes + refresh) can merge and soak **before** **Merge when:** CI green; smoke test link + session on staging Turso. -### PR 2 — Services second (`RaidHub-Services` → `feat/discord-linked-roles`) +### PR 2 — Services second (`Services` → `feat/discord-linked-roles`) **Goal:** Post–new-instance, debounced push to Discord using Turso read + Postgres stats. From 0cfc08ea6a3d127d17780b0c28284e6568530dea Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 3 May 2026 23:01:15 -0400 Subject: [PATCH 3/4] feat(linked-roles): Discord OAuth, BFF enqueue, account UX - Discord scopes identify + role_connections.write; session refresh via discordTokenRefresh. - pushLinkedRoleMetadata + internal queue; authEvents post-link sync; tRPC discordLinkedRolesStatus (syncHealth) + pushDiscordLinkedRoles. - DiscordLinkedRolesPanel + linkedRoleSyncError sanitizer; RaidHubError envelope helper. - Prisma account sync columns + migration; openapi/types aligned with API. - Account page refresh; remove deprecated account components; internalPaths for typed internal POSTs. Co-authored-by: Cursor --- README.md | 1 - docs/DISCORD_LINKED_ROLES.md | 428 ------- docs/LINKED_ROLES_EXECUTION.md | 80 -- example.env | 6 +- .../migration.sql | 3 + prisma/schema.prisma | 4 + src/app/account/Client.tsx | 16 +- .../__deprecated__/account/Account.tsx | 162 --- .../__deprecated__/account/Connection.tsx | 38 - .../account/SpeedrunAPIKeyModal.tsx | 123 -- .../__deprecated__/account/account.module.css | 136 --- .../account/AccountConnectionCard.tsx | 61 + src/components/account/AccountPage.tsx | 246 ++++ .../account/DiscordLinkedRolesPanel.tsx | 110 ++ .../ProfileIconForm.tsx} | 73 +- .../account/SpeedrunAPIKeyDialog.tsx | 128 +++ src/lib/server/auth/authEvents.ts | 25 + src/lib/server/auth/discordTokenRefresh.ts | 106 ++ src/lib/server/auth/index.ts | 5 +- src/lib/server/auth/sessionCallback.ts | 14 +- src/lib/server/auth/types.ts | 2 + src/lib/server/discord/linkedRoleSyncError.ts | 19 + .../server/discord/pushLinkedRoleMetadata.ts | 79 ++ src/lib/server/trpc/error-handler.ts | 55 +- .../user/discordLinkedRolesStatus.ts | 77 ++ .../procedures/user/pushDiscordLinkedRoles.ts | 10 + src/lib/server/trpc/router.ts | 4 + src/services/discord/webhook.ts | 43 - src/services/raidhub/RaidHubError.ts | 12 + src/services/raidhub/common.ts | 28 +- src/services/raidhub/internalPaths.ts | 24 + src/services/raidhub/openapi.d.ts | 1020 +++++++++++++---- src/services/raidhub/types.ts | 22 +- src/types/api.ts | 3 + 34 files changed, 1849 insertions(+), 1314 deletions(-) delete mode 100644 docs/DISCORD_LINKED_ROLES.md delete mode 100644 docs/LINKED_ROLES_EXECUTION.md create mode 100644 prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql delete mode 100644 src/components/__deprecated__/account/Account.tsx delete mode 100644 src/components/__deprecated__/account/Connection.tsx delete mode 100644 src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx delete mode 100644 src/components/__deprecated__/account/account.module.css create mode 100644 src/components/account/AccountConnectionCard.tsx create mode 100644 src/components/account/AccountPage.tsx create mode 100644 src/components/account/DiscordLinkedRolesPanel.tsx rename src/components/{__deprecated__/account/IconUploadForm.tsx => account/ProfileIconForm.tsx} (50%) create mode 100644 src/components/account/SpeedrunAPIKeyDialog.tsx create mode 100644 src/lib/server/auth/authEvents.ts create mode 100644 src/lib/server/auth/discordTokenRefresh.ts create mode 100644 src/lib/server/discord/linkedRoleSyncError.ts create mode 100644 src/lib/server/discord/pushLinkedRoleMetadata.ts create mode 100644 src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts create mode 100644 src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts delete mode 100644 src/services/discord/webhook.ts create mode 100644 src/services/raidhub/internalPaths.ts diff --git a/README.md b/README.md index e78df36f..ff7e7152 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ TBD - CDN: AWS S3, Cloudflare - ORM: Prisma - Database Provider: Turso -- Discord Linked Roles: [spec + ship checklist](./docs/DISCORD_LINKED_ROLES.md) · [execution plan (PR order)](./docs/LINKED_ROLES_EXECUTION.md) # Developer Guidelines diff --git a/docs/DISCORD_LINKED_ROLES.md b/docs/DISCORD_LINKED_ROLES.md deleted file mode 100644 index 58a05609..00000000 --- a/docs/DISCORD_LINKED_ROLES.md +++ /dev/null @@ -1,428 +0,0 @@ -# Discord Linked Roles — PRD, evaluation, and architecture (Option A) - -Cross-repo plan. **Parts I–VI** = requirements and architecture; **Part VII** = **v1 ship spec** (enough detail to implement and deploy in one pass). Update Part VII when reality diverges. - -**Branches & PR order:** [LINKED_ROLES_EXECUTION.md](./LINKED_ROLES_EXECUTION.md). - ---- - -## Part I — Product requirements (PRD) - -### Problem - -Players want Discord servers to grant roles from RaidHub-visible criteria (e.g. clears). Admins use Discord Linked Roles. RaidHub already separates **web auth** (Bungie + linked providers) from **raid data** (Postgres / services). - -### Actors - -- **Player** — link Discord, understand sync status, recover when broken. -- **Guild admin** — rules match documented metadata; roles update after criteria change. -- **Operators** — tokens safe, costs bounded, observable failures. - -### Functional requirements - -| ID | Requirement | -|----|----------------| -| FR-01 | Signed-in user can **connect** Discord to their RaidHub (Bungie) identity. | -| FR-02 | User can **disconnect** or **replace** linked Discord; effect on Discord roles follows Discord product rules. | -| FR-03 | **Consent** for scopes needed for linked roles (copy + legal outside this doc). | -| FR-04 | When **authoritative** RaidHub stats that feed linked-role metadata **change**, that user’s Discord application role connection metadata is **updated** so Discord can re-evaluate (within **NFR-08** SLO). | -| FR-04a | **Authoritative moment (clears path):** For stats updated in the **same Postgres transaction** as new instance storage (e.g. `player_stats` / `player` clears in `lib/services/instance_storage/instance.go`), metadata used for those fields **MUST** be computed **after** that transaction commits. **Primary sync trigger:** post-commit on new instance (debounced per user — Part III). | -| FR-04b | **Deferred metrics:** Any metadata field sourced from data **not** guaranteed at instance commit (e.g. future fields tied only to `player_crawl` side effects) **MUST** declare a **secondary trigger** (e.g. post-crawl queue) or accept higher staleness in the product matrix. | -| FR-05 | Metadata schema can **evolve** within Discord’s linked-role constraints. | -| FR-06 | User-visible **sync health**: linked or not, and whether RaidHub recently **successfully** pushed metadata (or “needs reconnect” / “pending”). | -| FR-07 | On Discord auth failure, user has a **clear reconnect** path without losing Bungie account. | -| FR-08 | No presenting eligibility RaidHub cannot justify from last successful push + known stats rules (product copy balances honesty vs Discord lag). | -| FR-09 | Admins see metadata fields **consistent** with RaidHub documentation. | -| FR-10 | Only **intended** metadata keys go to Discord linked roles. | - -### Non-functional requirements - -| ID | Requirement | -|----|----------------| -| NFR-01 | **Source of truth** for auth and **Discord ↔ Bungie** linkage + OAuth tokens: **RaidHub-Website DB** (Turso + Prisma / NextAuth). | -| NFR-02 | **Source of truth** for raid clears and gameplay aggregates: **RaidHub-Services Postgres** (and related stores), not Turso. | -| NFR-03 | OAuth secrets: least privilege, no logging of tokens, rotation story for operators. | -| NFR-04 | Linking/login UX **does not** hard-depend on worker health; **staleness** may increase if workers fail (see NFR-08). | -| NFR-05 | Discord + internal **rate limits**: coalesce/debounce; caps per time window. | -| NFR-06 | Metrics: attempts, success, failure class, latency; avoid high-cardinality PII in labels. | -| NFR-07 | Deletion / unlink: retention and cleanup align with policies (existing Prisma relations). | -| NFR-08 | **Eventual consistency** with explicit **max staleness SLO** (product sets numeric target). | -| NFR-09 | **Single writer** for OAuth token **refresh** unless an ADR introduces a second writer with merge rules. | -| NFR-10 | No second **authoritative** auth DB; read replicas / projections are non-authoritative. | - ---- - -## Part II — PRD evaluation (self-check) - -| Area | Assessment | -|------|------------| -| FR-04 split | **Fixed:** FR-04a/04b remove ambiguity between “instance committed” vs “async crawl” using **code-verified** behavior: many clear counters update **pre-commit** in `instance_storage.Store` → **post-commit enqueue** is sufficient for those fields. | -| FR-06 | Still needs **one** chosen persistence (Part III §6). | -| FR-08 vs NFR-08 | Product must set **SLO** numeric target; engineering implements debounce + queue lag budgets under it. | -| NFR-09 | **Critical:** worker must not silently fork token refresh without BFF coordination. | - ---- - -## Part III — Architecture (Option A): workers read Turso - -### Goals - -- **Writes:** NextAuth / BFF only for `account` token rows and linkage (NFR-01, NFR-09 default). -- **Reads:** Go workers (Hermes-managed queue) read Turso with a **dedicated credential** to resolve Discord OAuth + map **Destiny `membershipId`** (from instances) → **Bungie id** → `account` (`provider = discord`). -- **Stats:** Read from Postgres after commit (NFR-02); same transaction already updates many aggregates used for clears-style metadata (**FR-04a**). - -### Context diagram - -```mermaid -flowchart LR - subgraph bff["RaidHub-Website"] - NA[NextAuth] - end - Turso[("Turso SoT auth/link/tokens")] - NA --> Turso - - subgraph ingest["RaidHub-Services"] - IS[instance_store] - PG[(Postgres)] - RMQ[RabbitMQ] - WR[discord_metadata worker] - Redis[(Redis)] - end - IS --> PG - IS --> RMQ - RMQ --> WR - WR --> Redis - WR --> Turso - WR --> PG - WR --> Discord[Discord API role connection] -``` - -### Sequence (happy path) - -```mermaid -sequenceDiagram - participant IS as instance_store - participant PG as Postgres - participant RMQ as RabbitMQ - participant W as discord_metadata worker - participant TS as Turso read - participant D as Discord - - IS->>PG: Store + updatePlayerStats in txn, commit - IS->>RMQ: enqueue sync candidates (per player debounce key) - Note over W: Consumer coalesces by destiny/bungie id - W->>TS: read discord account row - alt not linked - W-->>W: no-op + metric - else linked - W->>PG: read aggregates for metadata fields - W->>D: PATCH role connection Bearer=user token - W->>TS: optional scoped write sync timestamp - end -``` - -### Identity resolution (repo-accurate) - -- **Instance DTO** (`RaidHub-Services/lib/dto/instance.go`): players carry **Destiny** `membershipId` / `membershipType` in `PlayerInfo`. -- **Turso** (`RaidHub-Website/prisma/schema.prisma`): `destiny_profile.destiny_membership_id` → `bungie_membership_id` → `account` row for Discord. -- Resolver: **Destiny membership id (+ type if needed)** → SQL against Turso → Discord `providerAccountId` + tokens. - -### Hermes / messaging - -- **Queue name (v1):** `discord_role_metadata_sync` — constant in `RaidHub-Services/lib/messaging/routing/constants.go`, worker file `lib/messaging/queue-workers/discord_role_metadata_sync.go` (or equivalent), register in `RaidHub-Services/apps/hermes/main.go` next to other topics. -- **Producer:** `RaidHub-Services/lib/services/instance_storage/orchestrator.go` — after successful `tx.Commit()` and **only when** `instanceIsNew` (same gate as `InstanceParticipantRefresh` today), publish one message per **distinct** `inst.Players[i].Player.MembershipId` (Destiny membership id, int64). **Payload shape — see Part VII §3.** -- **Debounce:** Redis — see Part VII §4. - -### Turso credentials - -| Token type | Purpose | -|------------|---------| -| Read (SELECT on `account`, `destiny_profile`, `bungie_user`) | Resolve linkage + read `access_token` / `refresh_token` / `expires_at` / `scope`. | -| Optional narrow write | **Only** if FR-06 chooses worker-updated columns (see §6); limit to `UPDATE ... SET discord_metadata_synced_at`, etc. Never arbitrary OAuth writes from Go without ADR. | - -### Discord OAuth (Website) gaps today - -- `RaidHub-Website/src/lib/server/auth/index.ts`: Discord authorize URL uses **`scope=identify` only** — linked roles need **`role_connections.write`** (and retain `identify` for linking). **Re-link** required for existing Discord-linked users after scope change (**FR-03**). - -### Keeping Discord OAuth up to date (NFR-03, NFR-09, FR-07) - -**Current codebase behavior** - -- Discord tokens are stored on **link** via `PrismaAdapter.linkAccount` (`account.access_token`, `refresh_token`, `expires_at`, `scope`). -- **Bungie** tokens are proactively refreshed in `sessionCallback` (`refreshBungieAuth`); **RaidHub** JWT is refreshed there too. There is **no** equivalent **Discord** refresh on session load today — Turso can hold an **expired** Discord `access_token` until the user re-authenticates with Discord or something else updates the row. - -**What “up to date” must cover** - -1. **Access token** — short-lived; must be refreshed before calling Discord APIs (linked-role metadata PATCH, or any `@me` call). -2. **Refresh token** — Discord may rotate it on refresh; persist the **new** refresh token whenever Discord returns one. -3. **Scopes** — adding `role_connections.write` requires **re-consent**; old rows are insufficient until the user completes OAuth again (**FR-03**). -4. **Revocation** — user disconnects app in Discord or RaidHub unlinks; Turso row removed/updated; workers must **no-op** gracefully. - -**Recommended implementation (BFF as single refresh writer)** - -| Mechanism | Role | -|-----------|------| -| **Session-time refresh** | Extend server session path (same idea as `refreshBungieAuth` in `sessionCallback.ts`): if user has `account` where `provider = discord` and access token missing or `expires_at` within a **skew buffer** (e.g. 5 minutes), call Discord `POST https://discord.com/api/oauth2/token` with `grant_type=refresh_token`, then **`prisma.account.update`** for that provider row. Runs whenever an authenticated session is loaded — keeps Turso warm for users who use the site. | -| **On-demand refresh** | Before BFF-only actions (“Sync linked roles” button) or before any BFF-initiated Discord user API call, run the same helper if near expiry. | -| **Adapter / getUser** | Ensure any code path that loads the user for session includes enough `accounts` data to decide if Discord refresh is needed (today `getUser` / session paths are Bungie-centric in places — implementation must load the Discord `account` row when implementing refresh). | -| **Worker (Hermes)** | v1: **read** token from Turso, call Discord; on **401** / invalid grant → metrics + surface **reconnect** (FR-06/07); **do not** refresh from Go unless a future ADR adds a **single** Turso write path for token rotation. | -| **Optional safety net** | Scheduled job (e.g. daily) calling the **same** refresh helper for users with linked Discord who are “due” — low frequency, bounded batch, same BFF-owned code to avoid splitting refresh logic. Only if session-only refresh leaves too many stale tokens for **inactive** users who never open the site. | - -**Why this matters for linked roles** - -Background workers can run **minutes after** a raid commit while the player is **not** on the website. If Discord `access_token` is already expired and nothing has refreshed it, the worker’s PATCH fails until the user hits the site (session refresh) or you add worker refresh + write-back (ADR). Tight **access token TTL + session refresh** minimizes that gap; optional **scheduled BFF refresh** narrows it for inactive users if product requires. - -### Token refresh policy summary (NFR-09) - -- **Default:** only **BFF** refreshes Discord OAuth and **writes** Turso `account` rows. -- **Worker:** use stored access token; on hard auth failure, **no** Go refresh in v1; user reconnect flow. -- **Optional ADR:** worker refresh with one controlled Turso `UPDATE` for tokens only. - -### Part IV — Traceability (plan vs PRD) - -| PRD | Plan coverage | -|-----|----------------| -| FR-01–03, 07 | Website OAuth + scopes; reconnect UX. | -| FR-04 + 04a | Post-commit queue from `orchestrator.go`; stats from committed Postgres. | -| FR-04b | Explicit per-field trigger table when new metadata added. | -| FR-05–10 | Metadata module + Discord app schema versioning + docs site. | -| NFR-01–02 | Turso read + Postgres reads; no duplicate SoT. | -| NFR-03–07 | Read token, metrics, no token logs, Redis debounce. | -| NFR-08–10 | SLO TBD product; single refresh writer default. | - -### Part V — Codebase alignment checklist - -| Component | Today | Plan touch | -|-----------|--------|------------| -| `RaidHub-Website` … `auth/index.ts` | Discord `identify` only | Add `role_connections.write`. | -| `RaidHub-Website` … `sessionCallback.ts` | Refreshes Bungie + RaidHub JWT only | Add **Discord** access-token refresh (mirror Bungie pattern) so Turso stays valid for workers. | -| `RaidHub-Website` … `prisma/schema` | `account` holds tokens | Optional columns for FR-06. | -| `RaidHub-Services` … `orchestrator.go` | Publishes subscription stage 1 | Also publish discord sync intent. | -| `RaidHub-Services` … `routing/constants.go` | No discord metadata queue | Add constant + worker. | -| `RaidHub-Services` … Hermes | Registers topics | Register new topic. | -| Go | No Turso | New `lib/database/turso` or similar + env. | -| `RaidHub-API` | User JWT + Discord **invocation** JWT | Unchanged for linked roles v1 (different concern than Turso user OAuth). | -| `raidhub-discord` Python | Slash commands → API | Unchanged for linked roles v1. | - -### Part VI — Deferred (post-v1 or product-owned) - -- **NFR-08 numeric SLO** — set in monitoring runbooks once traffic is observed (Part VII gives interim targets). -- **Worker-side OAuth refresh** + Turso token write-back — only via ADR if session + optional cron are insufficient. -- **Metadata fields** tied exclusively to `player_crawl` — add **FR-04b** second consumer when those fields ship. - ---- - -## Part VII — v1 ship spec (compact execution) - -Use this section as the **single implementation brief**. Assumes Option A (Hermes worker reads Turso + Postgres, pushes Discord). - -**Cross-repo review (automated + schema audit, 2026-05-03):** Prisma `Account.userId` maps to SQL **`account.bungie_membership_id`** (join fix in §5). `core.player.membership_id` is **`BIGINT`**. Hermes declares queues in **`apps/hermes/topic_manager.go`**, not `lib/messaging/processing`. NextAuth provider id remains **`discord`**. - -### 1) Locked v1 decisions (no bikeshedding for first ship) - -| Topic | v1 choice | -|-------|-----------| -| FR-06 | **Metrics-first:** Prometheus counters/histograms on worker + BFF refresh failures; **no** new Prisma columns required for first prod ship. Add Turso `account` sync columns in v1.1 if UX needs “last synced” in-app. | -| Queue payload | **One Rabbit message per affected Destiny `membership_id`** per new instance (dedupe distinct players in producer). | -| Debounce TTL | **300 seconds** per Destiny membership id (Redis). | -| Worker token refresh | **No** — BFF-only refresh; worker on 401 → metric + stop. | -| Feature gate | **Env** `DISCORD_LINKED_ROLES_ENABLED` (Go): when `false` / `0` / unset, **producer** in `orchestrator.go` **must not** publish (no queue backlog). **Hermes** still registers the topic so deploys are uniform; worker **first line** may also no-op when disabled to drain any in-flight messages after a flag-off rollback. Document in `RaidHub-Services/example.env`. | -| Interim SLO | Target **P95** queue wait + processing **< 15 minutes** under normal load; tune after metrics. | - -### 2) Discord Developer Portal (before code merge) - -1. Same **Discord Application** as production OAuth client used by `RaidHub-Website` (`DISCORD_CLIENT_ID`). -2. **Linked roles** → configure **Application Role Connection Metadata** (field keys you will send in JSON `metadata` map — use **snake_case** keys matching Discord schema, values **strings** per Discord API). -3. Note **Application ID** (often equals client id) for URL path — store as `DISCORD_APPLICATION_ID` in worker env (verify in portal if differ). -4. OAuth2 redirect URLs unchanged unless you add routes. -5. After scope change, communicate **“Reconnect Discord”** for existing linked users. - -**References (official):** - -- [Configuring app metadata for linked roles](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles) -- [Application Role Connection Metadata object](https://discord.com/developers/docs/resources/application-role-connection-metadata) -- [Update Current User Application Role Connection](https://discord.com/developers/docs/resources/user#update-current-user-application-role-connection) — **`PUT`** `https://discord.com/api/v10/users/@me/applications/{application.id}/role-connection` (requires OAuth2 access token with **`role_connections.write`** for that `application.id`). - -### 3) Rabbit message schema (v1) - -**Queue:** `discord_role_metadata_sync` - -**Body (JSON):** - -```json -{ - "schemaVersion": 1, - "trigger": "instance_new", - "destinyMembershipId": "12345678901234567890", - "instanceId": 16787546313 -} -``` - -| Field | Type | Notes | -|-------|------|--------| -| `schemaVersion` | int | Bump when payload incompatible. | -| `trigger` | string | v1: always `instance_new`. | -| `destinyMembershipId` | string | Decimal string of `PlayerInfo.MembershipId` from `dto.Instance` (JSON marshals int64; consumer accepts string for bigint safety). | -| `instanceId` | int64 | For logs/metrics correlation only; worker may ignore for metadata computation. | - -**Producer:** `orchestrator.go` after commit, inside `if instanceIsNew { ... }`, loop `inst.Players`, build `set` of `MembershipId`, for each publish one message. **Guard** with `DISCORD_LINKED_ROLES_ENABLED`. - -### 4) Redis debounce (v1) - -- **Key:** `discord_lr:debounce:{destinyMembershipId}` (string id). -- **Op:** `SET key 1 NX EX 300` — if `SET` fails (key exists), consumer **acks without work** (metric: `discord_lr_debounce_skip_total`). -- **Where:** Hermes worker process (same Redis singleton as clan cache — `RaidHub-Services/lib/database/redis`). - -### 5) Turso read contract (v1) - -**Driver:** Go `database/sql` + Turso/libSQL official client (see [Turso Go SDK](https://docs.turso.tech/sdk/go/reference) at ship time; package/import path may change — pin version in `go.mod`). - -**Env (Services):** - -| Variable | Required | Purpose | -|----------|----------|---------| -| `TURSO_AUTH_DB_URL` | yes | `libsql://...` URL for **read** token (prefer read-only token from Turso dashboard). | -| `TURSO_AUTH_DB_TOKEN` | yes | Auth token for that URL. | - -**Resolve Discord row by Destiny membership id** (SQLite table names from Prisma `@@map`; Prisma field `Account.userId` → SQL column **`bungie_membership_id`**, not `user_id`): - -```sql -SELECT a.access_token, a.refresh_token, a.expires_at, a.scope, a.provider_account_id -FROM account AS a -INNER JOIN destiny_profile AS d - ON d.bungie_membership_id = a.bungie_membership_id -WHERE a.provider = 'discord' - AND d.destiny_membership_id = ? -LIMIT 1; -``` - -- `?` = string destiny id (matches `destiny_profile.destiny_membership_id` text). -- If **`destiny_profile.bungie_membership_id` is NULL** for that row, the join yields no account — treat as **unlinked** (same metric path as missing Discord account). -- If **no row:** increment `discord_lr_unlinked_total`, return (success no-op). -- **Never** log `access_token` / `refresh_token`. -- **`expires_at`:** stored as Unix **seconds** (Prisma `Int?`); compare with `time.Now().Unix()` in worker when deciding whether token is likely stale (still prefer BFF refresh policy; worker may proceed and rely on Discord HTTP status). - -### 6) Postgres reads for metadata (v1 minimal) - -Implement **one** metadata builder in the worker (same package pattern as other Postgres access — use existing `postgres.DB` / `search_path`). - -- **Table:** `core.player` — column `clears` (see `infrastructure/postgres/migrations/002_core_schema.sql` and `lib/services/instance_storage/instance.go` `UPDATE player`). -- **Join key:** `core.player.membership_id` (**`BIGINT`**, not `INTEGER`) = int64 parsed from `destinyMembershipId` message field. Application SQL elsewhere uses unqualified `player` and relies on DB **`search_path`** including `core` (`infrastructure/postgres/init/setup.sql`); qualified `core.player` is safest in new worker SQL. -- **v1 example metadata map:** `{ "": "" }` — `` must **exactly** match a key in Discord **Application Role Connection Metadata** (portal may type it as INTEGER; HTTP body still uses **string** values per [API](https://discord.com/developers/docs/resources/user#update-user-application-role-connection)). -- **Extension:** per-activity clears from `core.player_stats` when product registers more metadata fields (same worker, same txn as `core.player` read). - -### 7) Discord HTTP call (worker) - -- **Method:** `PUT` -- **URL:** `https://discord.com/api/v10/users/@me/applications/{DISCORD_APPLICATION_ID}/role-connection` -- **Header:** `Authorization: Bearer {access_token from Turso}` -- **Header:** `Content-Type: application/json` -- **Body (JSON params per Discord):** all keys optional, but linked roles need **`metadata`** populated. Minimum v1: `{ "platform_name": "RaidHub", "metadata": { "": "" } }`. Optional: `platform_username` (e.g. Bungie global name) — max 100 chars per API. **`platform_name`** max 50 chars. Keys in `metadata` must match **Application Role Connection Metadata** keys from the Developer Portal. - -**Errors:** - -| Condition | Action | -|-----------|--------| -| HTTP 401 / invalid OAuth | `discord_lr_discord_auth_fail_total`; do not retry body indefinitely — DLQ or limited retry per Hermes policy. | -| HTTP 429 | Respect `Retry-After`; Hermes retry should backoff. | -| 5xx | Retry with existing worker retry semantics. | - -### 8) Website (BFF) — required code paths - -| Step | File / area | Action | -|------|-------------|--------| -| W1 | `src/lib/server/auth/index.ts` | Discord authorize URL: scopes **`identify` + `role_connections.write`** (space-separated in `scope` query param). | -| W2 | `src/lib/server/auth/sessionCallback.ts` (+ small `discordRefresh.ts`) | Load Discord `account` for `user.id`; if `expires_at` null or within **300s** of expiry, `POST https://discord.com/api/oauth2/token` with `client_id`, `client_secret`, `grant_type=refresh_token`, `refresh_token`; update `account` access/refresh/expires. | -| W3 | Adapter `getUser` / session includes | Ensure session load can read Discord `account` fields needed for W2 (extend Prisma `include` where only Bungie `accounts` is loaded today — `adapter.ts` paths). | -| W4 | (Optional v1) | Server action “Sync Discord roles” calling same refresh helper then **same** `PUT` role-connection as worker **or** rely on worker only — product choice; if omitted, inactive users depend on session visits for token freshness. | - -**Website env:** reuse `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` for refresh endpoint. - -### 9) Observability (minimum viable) - -| Metric (Prometheus) | Type | Labels (low cardinality) | -|---------------------|------|----------------------------| -| `discord_lr_publish_total` | counter | `result` = ok \| fail | -| `discord_lr_work_total` | counter | `result` = ok \| debounce_skip \| unlinked \| discord_4xx \| discord_5xx \| panic | -| `discord_lr_work_duration_seconds` | histogram | none or `result` ok only | - -Logs: always log `destinyMembershipId`, `instanceId`, `trigger`; never log tokens. - -### 10) Rollout checklist (prod order) - -1. Portal: metadata schema published. -2. Deploy **Website** with new scopes + refresh logic; monitor OAuth errors. -3. Run comms: existing users **Reconnect Discord**. -4. Create Turso **read** credential; store in secrets manager for **Hermes** (not in repo). -5. Set `DISCORD_APPLICATION_ID` + `TURSO_*` + `DISCORD_LINKED_ROLES_ENABLED=false` on workers. -6. Deploy **Services** binary with producer + worker + Redis debounce **disabled**. -7. Enable `DISCORD_LINKED_ROLES_ENABLED=true` on **canary** Hermes; watch metrics + Discord dev dashboard. -8. Full enable; set alert on `discord_lr_discord_auth_fail_total` rate. - -**Rollback:** set `DISCORD_LINKED_ROLES_ENABLED=false`; redeploy or hot-reload env; queue drains or DLQ clears per ops policy. - -### 11) Environment variables (copy checklist) - -**RaidHub-Services (Hermes / worker + producer)** - -| Variable | Example | Who sets | -|----------|---------|----------| -| `DISCORD_LINKED_ROLES_ENABLED` | `true` / `false` | ops | -| `DISCORD_APPLICATION_ID` | snowflake string | ops (Portal → Application ID) | -| `TURSO_AUTH_DB_URL` | `libsql://...` | ops (read-capable token) | -| `TURSO_AUTH_DB_TOKEN` | secret | ops | - -**RaidHub-Website (existing + behavior)** - -| Variable | Notes | -|----------|--------| -| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Already used for OAuth; refresh token POST reuses these. | - -### 12) Local / CI dev notes - -- **Website local DB** is file SQLite (`APP_ENV=local`) — no Turso unless pointed at branch; **worker integration** against Turso needs `TURSO_*` to a dev database or skip worker in CI. -- **Hermes** needs Redis + Rabbit + Postgres + Turso reachable from Docker network if worker runs in compose. -- Add **`example.env` entries** in `RaidHub-Services` for all new vars (copy-paste documented). - -### 13) Explicit non-goals (v1) - -- No changes to `RaidHub-API` user JWT, `raidhub-discord` Python bot, or subscription webhooks for linked roles. -- No Postgres table for Discord user id (Turso remains SoT). -- No worker-written OAuth tokens. - -### 14) One-page implementation order (for agents / humans) - -1. Portal: metadata keys + linked roles tutorial complete. -2. Website: scopes (`index.ts`). -3. Website: Discord refresh + adapter/session includes (`sessionCallback`, `adapter.ts`, small helper). -4. Services: `example.env` + register vars in `lib/env/env.go` (`getEnv` / `getEnvWithDefault` pattern — see existing `DISCORD_*` optional vars). -5. Services: `routing/constants.go` + message struct (same package as other `messages/*.go`). -6. Services: producer loop in `orchestrator.go` behind `DISCORD_LINKED_ROLES_ENABLED`. -7. Services: Turso client package + resolver SQL (§5). -8. Services: worker topic + Redis debounce (§3–4) + Postgres metadata (§6) + Discord PUT (§7). -9. Services: Hermes `main.go` register topic. -10. Metrics + dashboards (§9). -11. Staging e2e: link Discord → finish raid → observe PUT + role in test server. -12. Prod rollout (§10). - -### 15) RabbitMQ / Hermes wiring note - -Queues are **not** statically listed in `infrastructure/rabbitmq/definitions.json` (empty `queues` array). **Declaration:** `RaidHub-Services/apps/hermes/topic_manager.go` — durable queue named `TopicConfig.QueueName`, bound to delayed exchange `hermes.delayed` with routing key = queue name, then consume (same as all Hermes topics). - -**Registration:** append `qw.YourTopic()` to the `topics` slice in `apps/hermes/main.go` (see lines ~77–89 today). - -**TopicConfig:** defined in `lib/messaging/processing/topic.go`. For outbound Discord HTTP (no Bungie), mirror **`subscription_delivery.go`** (prefetch `1`, `KeepInReady: true`, higher `MaxRetryCount`, custom retry delay for 429). Do **not** add `BungieSystemDeps` unless the worker calls Bungie. - -**Publish:** `publishing.PublishJSONMessage(ctx, routing., payload)` — queue name string must match `routing` constant exactly. - ---- - -## Revision history - -| Date | Change | -|------|--------| -| 2026-05-03 | Initial consolidated PRD + Option A plan; FR-04a grounded in `instance_storage` transactional clears update. | -| 2026-05-03 | §Keeping Discord OAuth up to date: current gap vs session/on-demand/worker policy. | -| 2026-05-03 | **Part VII** v1 ship spec: locked defaults, schemas, SQL, APIs, env, rollout, non-goals. | -| 2026-05-03 | Part VII tightened: `core.player`, env table, impl order §14, Rabbit note §15. | -| 2026-05-03 | **Deep review:** fix Turso SQL join (`bungie_membership_id` not `user_id`); `BIGINT` + `search_path` note; Discord PUT + `role_connections.write`; Hermes `topic_manager.go`; lock feature-flag semantics; API body optional fields. | diff --git a/docs/LINKED_ROLES_EXECUTION.md b/docs/LINKED_ROLES_EXECUTION.md deleted file mode 100644 index 5256bbd8..00000000 --- a/docs/LINKED_ROLES_EXECUTION.md +++ /dev/null @@ -1,80 +0,0 @@ -# Discord Linked Roles — execution plan (branches + PR order) - -**Branches (created locally):** - -| Repo | Branch | Base | -|------|--------|------| -| [Services](https://github.com/Raid-Hub/Services) | `feat/discord-linked-roles` | `main` | -| [RaidHub-Website](https://github.com/Raid-Hub/Web-App) | `feat/discord-linked-roles` | `main` | - -**Canonical spec (in each repo):** [`docs/DISCORD_LINKED_ROLES.md`](./DISCORD_LINKED_ROLES.md) (Part VII = implementable checklist). - ---- - -## PR strategy (recommended) - -Ship in **two PRs** so Website (scopes + refresh) can merge and soak **before** workers start pushing to Discord. - -### PR 1 — Website first (`Web-App` → `feat/discord-linked-roles`) - -**Goal:** Users re-consent `role_connections.write`; Turso tokens stay fresh on session. - -- [ ] Discord authorize URL: `identify` + `role_connections.write` (`src/lib/server/auth/index.ts`). -- [ ] Discord OAuth refresh on session path + `prisma.account.update` (`sessionCallback.ts`, helper; extend `adapter.ts` / `getUser` includes for Discord `account` row). -- [ ] Copy/link spec: `docs/DISCORD_LINKED_ROLES.md` + README pointer to `./docs/DISCORD_LINKED_ROLES.md`. -- [ ] Comms / settings copy: “Reconnect Discord” for existing linked users. - -**Merge when:** CI green; smoke test link + session on staging Turso. - -### PR 2 — Services second (`Services` → `feat/discord-linked-roles`) - -**Goal:** Post–new-instance, debounced push to Discord using Turso read + Postgres stats. - -- [ ] `DISCORD_LINKED_ROLES_ENABLED`, `DISCORD_APPLICATION_ID`, `TURSO_AUTH_DB_URL`, `TURSO_AUTH_DB_TOKEN` in `lib/env/env.go` + `example.env`. -- [ ] `routing` constant + message type + `publishing` from `orchestrator.go` (gated flag). -- [ ] Turso read client + §5 SQL; Postgres metadata read (`core.player`); Redis debounce §4. -- [ ] Hermes topic + `main.go` registration; Discord `PUT` §7; metrics §9. -- [ ] Docs: `docs/DISCORD_LINKED_ROLES.md`, `docs/ARCHITECTURE.md` see-also, this file. - -**Merge when:** CI green; staging Hermes reaches Turso + Discord test app; rollout §10 dry-run with flag off then on. - -### PR 3 — Optional / later - -- [ ] In-app “Sync linked roles” + FR-06 Turso columns (v1.1). -- [ ] Worker token refresh ADR (only if metrics show mass 401 for inactive users). - ---- - -## Dependency rule - -**Do not enable** `DISCORD_LINKED_ROLES_ENABLED=true` in production until **PR 1** is deployed and users can obtain new scopes (otherwise workers will 401). - ---- - -## Push branches - -```bash -# Services -cd RaidHub-Services && git push -u origin feat/discord-linked-roles - -# Website -cd RaidHub-Website && git push -u origin feat/discord-linked-roles -``` - -Open PRs with title prefix: `feat(linked-roles): …` — link cross-repo PRs in descriptions. - ---- - -## Stash recovery (if needed) - -```bash -# If you had other WIP on old branches: -cd RaidHub-Services && git stash list # pop onto correct feature branch if relevant -cd RaidHub-Website && git stash list -``` - ---- - -## Out of scope (same as spec) - -- `RaidHub-API`, `raidhub-discord`, `subscription-webhook-relay` — no branches required for v1. diff --git a/example.env b/example.env index 53584b9d..6899d7f8 100644 --- a/example.env +++ b/example.env @@ -8,11 +8,15 @@ BUNGIE_CLIENT_SECRET="" # Required for login # RaidHub API RAIDHUB_API_URL="http://localhost:8000" # Defaults to https://api.raidhub.io when not set RAIDHUB_API_KEY="" # Required for accessing public domain, not required if self-hosting -RAIDHUB_CLIENT_SECRET="" # Required for accessing admin routes, can be set to a string of choice if self-hosting +RAIDHUB_CLIENT_SECRET="" # Admin/internal calls: sent as x-raidhub-client-secret (not in JSON). Match API CLIENT_SECRET if using linked-role sync. # Additional OAuth Providers for account linking # DISCORD_CLIENT_ID="" # DISCORD_CLIENT_SECRET="" +# Same Discord application as OAuth client (for linked-role PUT). Defaults to DISCORD_CLIENT_ID if unset. +# DISCORD_APPLICATION_ID="" +# Metadata key registered in Discord Developer Portal (integer field → string value in API). +# DISCORD_LINKED_ROLES_METADATA_KEY=raidhub_total_clears # TWITCH_CLIENT_ID="" # TWITCH_CLIENT_SECRET="" diff --git a/prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql b/prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql new file mode 100644 index 00000000..80941563 --- /dev/null +++ b/prisma/migrations/20260503213000_discord_linked_roles_sync/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "account" ADD COLUMN "discord_role_metadata_synced_at" DATETIME; +ALTER TABLE "account" ADD COLUMN "discord_role_metadata_sync_error" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45d9162c..b8e2f497 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,6 +91,10 @@ model Account { scope String? idToken String? @map("id_token") sessionState String? @map("session_state") + /// Last successful push of Discord linked-role metadata (Hermes or BFF). + discordRoleMetadataSyncedAt DateTime? @map("discord_role_metadata_synced_at") + /// Short machine-readable error from last failed push (if any). + discordRoleMetadataSyncError String? @map("discord_role_metadata_sync_error") user User @relation("UserToAccount", fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId], name: "uniqueProviderAccountId") diff --git a/src/app/account/Client.tsx b/src/app/account/Client.tsx index 90cbf0b6..5e906d43 100644 --- a/src/app/account/Client.tsx +++ b/src/app/account/Client.tsx @@ -1,8 +1,8 @@ "use client" import { Collection } from "@discordjs/collection" +import { AccountPage } from "~/components/account/AccountPage" import { ForceClientSideBungieSignIn } from "~/components/ForceClientSideBungieSignIn" -import Account from "~/components/__deprecated__/account/Account" export const Client = ({ providers @@ -15,13 +15,19 @@ export const Client = ({ }) => ( ( - <> -

Welcome, {session.user.name}

- +
+

Account

+

+ Profiles, profile icon, and linked services for{" "} + {session.user.name}. +

+
+ [p.id, p]))} /> - + )} /> ) diff --git a/src/components/__deprecated__/account/Account.tsx b/src/components/__deprecated__/account/Account.tsx deleted file mode 100644 index dfa821e9..00000000 --- a/src/components/__deprecated__/account/Account.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client" - -import { type Collection } from "@discordjs/collection" -import { type Session } from "next-auth" -import { signIn, signOut } from "next-auth/react" -import Link from "next/link" -import { useMemo, useRef } from "react" -import { DiscordIconOld } from "~/components/icons/DiscordIcon" -import { SpeedrunIcon } from "~/components/icons/SpeedrunIcon" -import TwitchIcon from "~/components/icons/TwitchIcon" -import TwitterIcon from "~/components/icons/TwitterIcon" -import YoutubeIcon from "~/components/icons/YoutubeIcon" -import { trpc } from "~/lib/trpc" -import styles from ".//account.module.css" -import Connection from "./Connection" -import IconUploadForm from "./IconUploadForm" -import SpeedrunAPIKeyModal from "./SpeedrunAPIKeyModal" - -type AccountProps = { - session: Session - providers: Collection< - string, - { - id: string - name: string - type: string - } - > -} - -const bungieMembershipTypeMap = { - "-1": "???", - 0: "???", - 1: "Xbox", - 2: "PSN", - 3: "Steam", - 4: "Blizzard", - 5: "Stadia", - 6: "Epic", - 10: "Demon", - 254: "Bungie.net" -} - -const Account = ({ session, providers }: AccountProps) => { - const { data: socialNames, refetch: refetchSocials } = trpc.user.getConnections.useQuery() - const { mutate: unlinkAccountFromUser } = trpc.user.removeByAccount.useMutation({ - onSuccess() { - void refetchSocials() - } - }) - const { mutate: deleteUserMutation } = trpc.user.delete.useMutation({ - onSuccess() { - window.location.href = "/" - }, - onError(error) { - console.error(error) - alert("An error occurred while deleting your account") - } - }) - const speedrunAPIKeyModalRef = useRef(null) - - const { discordProvider, twitchProvider, twitterProvider, youtubeProvider } = useMemo( - () => ({ - discordProvider: providers?.get("discord"), - twitchProvider: providers?.get("twitch"), - twitterProvider: providers?.get("twitter"), - youtubeProvider: providers?.get("youtube") - }), - [providers] - ) - - return ( - <> - -
-
- {session?.user.profiles.map(profile => ( - - - - ))} - - - -
-
-
-

Manage Account

- -
-
-

Manage Connections

-
- {discordProvider && ( - unlinkAccountFromUser({ providerId: "discord" })} - link={() => signIn("discord", {}, { prompt: "consent" })} - serviceName={discordProvider.name} - username={socialNames?.get("discord") ?? null} - Icon={DiscordIconOld} - /> - )} - {twitterProvider && ( - unlinkAccountFromUser({ providerId: "twitter" })} - link={() => signIn("twitter", {}, { force_login: "true" })} - serviceName={twitterProvider.name} - username={socialNames?.get("twitter") ?? null} - Icon={TwitterIcon} - /> - )} - {twitchProvider && ( - unlinkAccountFromUser({ providerId: "twitch" })} - link={() => signIn("twitch", {}, { force_verify: "true" })} - serviceName={twitchProvider.name} - username={socialNames?.get("twitch") ?? null} - Icon={TwitchIcon} - /> - )} - {youtubeProvider && ( - unlinkAccountFromUser({ providerId: "youtube" })} - link={() => signIn("youtube", {}, { prompt: "select_account" })} - serviceName={youtubeProvider.name} - username={socialNames?.get("youtube") ?? null} - Icon={YoutubeIcon} - /> - )} - unlinkAccountFromUser({ providerId: "speedrun" })} - link={() => speedrunAPIKeyModalRef.current?.showModal()} - serviceName="Speedrun.com" - username={socialNames?.get("speedrun") ?? null} - Icon={props => } - /> -
-
- - ) -} - -export default Account diff --git a/src/components/__deprecated__/account/Connection.tsx b/src/components/__deprecated__/account/Connection.tsx deleted file mode 100644 index 4c96feb9..00000000 --- a/src/components/__deprecated__/account/Connection.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { type SVGComponent } from "~/components/SVG" -import styles from "./account.module.css" - -export default function Connection({ - unlink, - link, - serviceName, - username, - Icon -}: { - username: string | null - serviceName: string - link: () => void - unlink: () => void - Icon: SVGComponent -}) { - const canLink = !username - - return ( -
-
-

{serviceName}

- {username} -
- -
-
-
- - -
-
- ) -} diff --git a/src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx b/src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx deleted file mode 100644 index c5eec9e9..00000000 --- a/src/components/__deprecated__/account/SpeedrunAPIKeyModal.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import Link from "next/link" -import React from "react" -import { useForm, type SubmitHandler } from "react-hook-form" -import { z } from "zod" -import { trpc } from "~/lib/trpc" -import styles from "./account.module.css" - -const errMsg = "Invalid API Key format: " -const zFormSchema = z.object({ - apiKey: z - .string() - .min(20, { message: errMsg + "too few characters" }) - .max(30, { message: errMsg + "too many characters" }) -}) - -type FormSchemaType = z.infer - -export default React.forwardRef void }>( - function SpeedrunAPIKeyModal({ refetchSocials }, ref) { - const closeModal = () => { - if (typeof ref === "object") { - ref?.current?.close() - } - } - const { - mutate: updateAPIKey, - isError, - error, - isLoading - } = trpc.user.createSpeedrunComAccount.useMutation({ - onSuccess() { - closeModal() - refetchSocials() - reset() - } - }) - const { - handleSubmit, - register, - formState: { errors }, - reset - } = useForm({ - resolver: zodResolver(zFormSchema) - }) - - const onSubmit: SubmitHandler = data => { - updateAPIKey(data) - } - - const err = isError ? error : errors.apiKey - - return ( - - -

Connect with Speedrun.com

-

- In order to authenticate with speedrun.com, you must paste your secret API key - into the text box below. You can access this key at{" "} - - speedrun.com/settings/api - -

-

- We will not ask for your username or password, though you might be prompted to - log in or create an account on speedrun.com if you are not logged in already. -

-

Full steps:

-
    -
  1. - Login to{" "} - - www.speedrun.com - -
  2. -
  3. Click on your user icon in the top right corner
  4. -
  5. - Select Settings in the drop down -
  6. -
  7. - Scroll down to the panel labeled Developers -
  8. -
  9. - Click API Key -
  10. -
  11. - Click Show API Key -
  12. -
  13. Copy the key
  14. -
  15. Paste the key into the text box on this page
  16. -
  17. - Press Submit -
  18. -
-

- We do not store your API key on our servers. We only use it to verify that you - own the account you are linking, and then the key is discarded. If you like, you - may click Regenerate next to your API key on speedrun.com to take extra - precaution. -

- -
- - - {err &&
{err.message}
} -
-
- ) - } -) diff --git a/src/components/__deprecated__/account/account.module.css b/src/components/__deprecated__/account/account.module.css deleted file mode 100644 index 9079d21e..00000000 --- a/src/components/__deprecated__/account/account.module.css +++ /dev/null @@ -1,136 +0,0 @@ -.section { - margin-bottom: 1em; -} - -.flex { - display: flex; - flex-direction: column; - flex-wrap: wrap; - align-content: flex-start; -} - -.buttons { - display: flex; - gap: 1em; - - flex-wrap: wrap; -} - -.glossy-bg { - border-radius: 10px; - padding: 2em; - background-color: #1a191941; -} - -.buttons button, -.form button { - border-radius: 15px; - border: none; - - font-weight: 800; - - text-transform: uppercase; - padding: 10px; - transition: background-color 0.2s ease-out; - cursor: pointer; -} - -.buttons button:hover:not(:disabled) { - background-color: #ed904e; - border-radius: 15px; - border: none; - - text-transform: uppercase; - padding: 10px; -} - -.destructive { - color: white; - background-color: rgb(225, 51, 51); -} - -.form { - display: flex; - flex-direction: row; - gap: 2em; - flex-wrap: wrap; -} -.form-element { - display: flex; - flex-direction: row; - - gap: 1em; -} -.form-element > div { - display: flex; - flex-direction: column; -} -.form button { - align-self: center; -} -.connections { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 450px)); - - gap: 2em; -} - -.connection-head { - display: flex; - flex-direction: row; - justify-content: flex-start; - - gap: 1em; - margin-bottom: 1em; -} -.connection-head h3 { - margin: 0; -} - -.social-icon-container { - margin-left: auto; -} - -.api-key-modal { - position: fixed; - z-index: 10; - - background-color: #1a191941; - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - - max-width: 700px; -} - -.api-key-modal button { - cursor: pointer; -} - -.api-key-modal-close-button { - position: absolute; - top: 0.7em; - right: 0.7em; -} - -.api-key-modal li { - padding: 0.5em; -} - -.api-key-modal a { - color: #ed904e; -} - -.api-key-modal em { - font-weight: 500; -} - -.api-key-modal form { - display: flex; - flex-direction: row; - gap: 1em; - flex-wrap: wrap; -} - -.api-key-modal-err { - color: red; -} diff --git a/src/components/account/AccountConnectionCard.tsx b/src/components/account/AccountConnectionCard.tsx new file mode 100644 index 00000000..119bea2e --- /dev/null +++ b/src/components/account/AccountConnectionCard.tsx @@ -0,0 +1,61 @@ +"use client" + +import type { ReactNode } from "react" +import { type SVGComponent } from "~/components/SVG" +import { Button } from "~/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" +import { cn } from "~/lib/tw" + +type AccountConnectionCardProps = { + serviceName: string + username: string | null + link: () => void + unlink: () => void + Icon: SVGComponent + footer?: ReactNode +} + +export function AccountConnectionCard({ + serviceName, + username, + link, + unlink, + Icon, + footer +}: AccountConnectionCardProps) { + const linked = Boolean(username) + + return ( + + +
+ +
+
+ {serviceName} + + {linked ? ( + <> + Linked as {username} + + ) : ( + "Not connected" + )} + +
+
+ +
+ + +
+ {footer} +
+
+ ) +} diff --git a/src/components/account/AccountPage.tsx b/src/components/account/AccountPage.tsx new file mode 100644 index 00000000..b95fcdda --- /dev/null +++ b/src/components/account/AccountPage.tsx @@ -0,0 +1,246 @@ +"use client" + +import { type Collection } from "@discordjs/collection" +import Link from "next/link" +import { useMemo, useRef } from "react" +import { type Session } from "next-auth" +import { signIn, signOut } from "next-auth/react" +import { DiscordIconOld } from "~/components/icons/DiscordIcon" +import { SpeedrunIcon } from "~/components/icons/SpeedrunIcon" +import TwitchIcon from "~/components/icons/TwitchIcon" +import TwitterIcon from "~/components/icons/TwitterIcon" +import YoutubeIcon from "~/components/icons/YoutubeIcon" +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import { trpc } from "~/lib/trpc" +import { AccountConnectionCard } from "./AccountConnectionCard" +import { DiscordLinkedRolesPanel } from "./DiscordLinkedRolesPanel" +import { ProfileIconForm } from "./ProfileIconForm" +import { SpeedrunAPIKeyDialog } from "./SpeedrunAPIKeyDialog" + +const bungieMembershipTypeLabel: Record = { + [-1]: "Unknown", + 0: "Unknown", + 1: "Xbox", + 2: "PSN", + 3: "Steam", + 4: "Battle.net", + 5: "Stadia", + 6: "Epic", + 10: "Demon", + 254: "Bungie.net" +} + +type AccountPageProps = { + session: Session + providers: Collection< + string, + { + id: string + name: string + type: string + } + > +} + +export function AccountPage({ session, providers }: AccountPageProps) { + const utils = trpc.useUtils() + const speedrunDialogRef = useRef(null) + const { data: socialNames, refetch: refetchSocials } = trpc.user.getConnections.useQuery() + + const refreshConnections = () => { + void refetchSocials() + void utils.user.discordLinkedRolesStatus.invalidate() + } + + const { mutate: unlinkAccountFromUser } = trpc.user.removeByAccount.useMutation({ + onSuccess: refreshConnections + }) + + const { mutate: deleteUserMutation } = trpc.user.delete.useMutation({ + onSuccess() { + window.location.href = "/" + }, + onError(error) { + console.error(error) + window.alert("An error occurred while deleting your account") + } + }) + + const { discordProvider, twitchProvider, twitterProvider, youtubeProvider } = useMemo( + () => ({ + discordProvider: providers.get("discord"), + twitchProvider: providers.get("twitch"), + twitterProvider: providers.get("twitter"), + youtubeProvider: providers.get("youtube") + }), + [providers] + ) + + const initial = session.user.name?.trim().charAt(0).toUpperCase() ?? "?" + + return ( +
+ + + + +
+ + {session.user.image ? ( + + ) : null} + {initial} + +
+
+ + {session.user.name} + + + Signed in with Bungie. Open a profile, tweak your icon, and link social + accounts below. + +
+
+ {session.user.profiles.map(profile => { + const label = + bungieMembershipTypeLabel[profile.destinyMembershipType] ?? + "Profile" + return ( + + ) + })} +
+
+ + +
+
+
+
+
+ +
+
+

Profile icon

+

+ Shown on RaidHub for your primary Destiny profile. +

+
+ +
+ +
+
+

Linked accounts

+

+ Connect services for your profile. For Discord role sync, use{" "} + Connect and approve{" "} + role_connections.write when prompted. +

+
+
+ {discordProvider ? ( + unlinkAccountFromUser({ providerId: "discord" })} + link={() => signIn("discord", {}, { prompt: "consent" })} + serviceName={discordProvider.name} + username={socialNames?.get("discord") ?? null} + Icon={DiscordIconOld} + footer={} + /> + ) : null} + {twitterProvider ? ( + unlinkAccountFromUser({ providerId: "twitter" })} + link={() => signIn("twitter", {}, { force_login: "true" })} + serviceName={twitterProvider.name} + username={socialNames?.get("twitter") ?? null} + Icon={TwitterIcon} + /> + ) : null} + {twitchProvider ? ( + unlinkAccountFromUser({ providerId: "twitch" })} + link={() => signIn("twitch", {}, { force_verify: "true" })} + serviceName={twitchProvider.name} + username={socialNames?.get("twitch") ?? null} + Icon={TwitchIcon} + /> + ) : null} + {youtubeProvider ? ( + unlinkAccountFromUser({ providerId: "youtube" })} + link={() => signIn("youtube", {}, { prompt: "select_account" })} + serviceName={youtubeProvider.name} + username={socialNames?.get("youtube") ?? null} + Icon={YoutubeIcon} + /> + ) : null} + unlinkAccountFromUser({ providerId: "speedrun" })} + link={() => speedrunDialogRef.current?.showModal()} + serviceName="Speedrun.com" + username={socialNames?.get("speedrun") ?? null} + Icon={props => } + /> +
+
+ + + +
+
+

Danger zone

+

+ Permanently delete your RaidHub account and associated data. This cannot be undone. +

+
+ + +

Delete your RaidHub account

+ +
+
+
+
+ ) +} diff --git a/src/components/account/DiscordLinkedRolesPanel.tsx b/src/components/account/DiscordLinkedRolesPanel.tsx new file mode 100644 index 00000000..f4d6689d --- /dev/null +++ b/src/components/account/DiscordLinkedRolesPanel.tsx @@ -0,0 +1,110 @@ +"use client" + +import { trpc } from "~/lib/trpc" +import { cn } from "~/lib/tw" +import { Button } from "~/components/ui/button" +import type { DiscordLinkedRoleSyncHealth } from "~/types/api" + +type DiscordLinkedRolesPanelProps = { + /** Nested under the Discord connection card (no duplicate outer chrome). */ + variant?: "standalone" | "embedded" +} + +function syncHealthBanner(health: DiscordLinkedRoleSyncHealth): { tone: "muted" | "amber" | "destructive"; text: string } | null { + switch (health) { + case "not_linked": + return null + case "needs_scope": + return { + tone: "amber", + text: "Reconnect Discord above and include consent for linked roles (scope role_connections.write)." + } + case "needs_reconnect": + return { + tone: "amber", + text: "Discord rejected the last metadata update. Disconnect and reconnect Discord above, then try Sync now." + } + case "pending": + return { + tone: "muted", + text: "RaidHub has not recorded a successful push yet. After your next qualifying raid completes—or if you use Sync now—status should update here when the worker finishes." + } + case "ok": + return { + tone: "muted", + text: "RaidHub last pushed your stats to Discord successfully. Each server applies linked roles on its own schedule; allow a few minutes before expecting a role change." + } + case "error": + return { + tone: "destructive", + text: "The last push did not succeed. Try Sync now. If it keeps failing, try reconnecting Discord or check back later." + } + default: + return null + } +} + +export function DiscordLinkedRolesPanel({ variant = "standalone" }: DiscordLinkedRolesPanelProps) { + const { data, refetch, isLoading } = trpc.user.discordLinkedRolesStatus.useQuery() + const push = trpc.user.pushDiscordLinkedRoles.useMutation({ + onSuccess() { + void refetch() + } + }) + + if (isLoading || !data?.linked) { + return null + } + + const embedded = variant === "embedded" + const banner = syncHealthBanner(data.syncHealth) + const bannerClass = + banner?.tone === "destructive" + ? "text-destructive" + : banner?.tone === "amber" + ? "text-amber-300/90" + : "text-muted-foreground" + + return ( +
+

Discord linked roles

+

+ RaidHub sends your linked-role metadata (for example clear totals) to Discord. Server admins map those + fields to roles in Discord; RaidHub does not assign Discord roles directly. +

+ {banner ?

{banner.text}

: null} + {data.lastSyncedAt ? ( +

+ Last synced: {new Date(data.lastSyncedAt).toLocaleString()} +

+ ) : null} + {data.lastError ? ( +

Error code: {data.lastError}

+ ) : null} +
+ +
+ {push.data && !push.data.ok ? ( +

+ Sync failed: {push.data.code} + {push.data.code === "enqueue_failed" + ? " — the queue may be full. Try again in a few minutes." + : push.data.detail + ? ` — ${push.data.detail}` + : ""} +

+ ) : null} +
+ ) +} diff --git a/src/components/__deprecated__/account/IconUploadForm.tsx b/src/components/account/ProfileIconForm.tsx similarity index 50% rename from src/components/__deprecated__/account/IconUploadForm.tsx rename to src/components/account/ProfileIconForm.tsx index d91adba1..8c1687d7 100644 --- a/src/components/__deprecated__/account/IconUploadForm.tsx +++ b/src/components/account/ProfileIconForm.tsx @@ -1,18 +1,21 @@ "use client" -import Image from "next/image" import { useState, type ChangeEventHandler } from "react" import { useForm, type SubmitHandler } from "react-hook-form" +import { toast } from "sonner" import { useSession } from "~/hooks/app/useSession" import { trpc } from "~/lib/trpc" import { uploadProfileIcon } from "~/services/s3/uploadProfileIcon" -import styles from "./account.module.css" +import { Button } from "~/components/ui/button" +import { Card, CardContent } from "~/components/ui/card" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" type FormValues = { image: File } -const IconUploadForm = () => { +export function ProfileIconForm() { const { data: session, update: updateSession } = useSession() const [imageSrc, setImageSrc] = useState(null) const [err, setErr] = useState(null) @@ -24,7 +27,7 @@ const IconUploadForm = () => { } = trpc.user.update.useMutation({ onSuccess: () => { void updateSession() - alert("Icon updated") + toast.success("Profile icon updated") } }) @@ -48,7 +51,7 @@ const IconUploadForm = () => { const successfulUpload = await uploadProfileIcon(data.image, signedURL) if (!successfulUpload) { - setErr(new Error("Failed to upload Image")) + setErr(new Error("Failed to upload image")) return } @@ -71,35 +74,55 @@ const IconUploadForm = () => { const handleFileChange: ChangeEventHandler = event => { const file = event.target.files?.[0] if (file) { - if (file.size > 256_000 /** 250 KB */) { - setErr(new Error("File too large. Max: 256kb")) + if (file.size > 256_000) { + setErr(new Error("File too large. Maximum size is 256 KB.")) resetField("image") setImageSrc(null) return } + setErr(null) setValue("image", file) setImageSrc(URL.createObjectURL(file)) } } return ( -
-
- {imageSrc && selected icon} -
- {" "} - -
-
- {err &&
{err.message}
} - {error &&
{error.message}
} - -
+ + +
+ {imageSrc ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- local object URL preview */} + Selected icon preview +
+ ) : null} +
+ + +
+ +
+ {err ? ( +

+ {err.message} +

+ ) : null} + {error ? ( +

+ {error.message} +

+ ) : null} +
+
) } - -export default IconUploadForm diff --git a/src/components/account/SpeedrunAPIKeyDialog.tsx b/src/components/account/SpeedrunAPIKeyDialog.tsx new file mode 100644 index 00000000..313b1936 --- /dev/null +++ b/src/components/account/SpeedrunAPIKeyDialog.tsx @@ -0,0 +1,128 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import Link from "next/link" +import React from "react" +import { useForm, type SubmitHandler } from "react-hook-form" +import { z } from "zod" +import { trpc } from "~/lib/trpc" +import { Button } from "~/components/ui/button" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" + +const errMsg = "Invalid API key format: " +const zFormSchema = z.object({ + apiKey: z + .string() + .min(20, { message: errMsg + "too few characters" }) + .max(30, { message: errMsg + "too many characters" }) +}) + +type FormSchemaType = z.infer + +export const SpeedrunAPIKeyDialog = React.forwardRef< + HTMLDialogElement, + { refetchSocials: () => void } +>(function SpeedrunAPIKeyDialog({ refetchSocials }, ref) { + const closeModal = () => { + if (typeof ref === "object") { + ref?.current?.close() + } + } + const { + mutate: updateAPIKey, + isError, + error, + isLoading + } = trpc.user.createSpeedrunComAccount.useMutation({ + onSuccess() { + closeModal() + refetchSocials() + reset() + } + }) + const { + handleSubmit, + register, + formState: { errors }, + reset + } = useForm({ + resolver: zodResolver(zFormSchema) + }) + + const onSubmit: SubmitHandler = data => { + updateAPIKey(data) + } + + const err = isError ? error : errors.apiKey + + return ( + + +

Connect Speedrun.com

+
+

+ Paste your secret API key below. You can find it at{" "} + + speedrun.com/settings/api + + . +

+

+ We will not ask for your username or password. We only use the key once to verify + you own the account, then discard it. +

+

Steps

+
    +
  1. + Log in to{" "} + + speedrun.com + +
  2. +
  3. Open your user menu → Settings
  4. +
  5. Under Developers, open API Key → Show API Key
  6. +
  7. Copy the key and paste it here, then submit
  8. +
+

+ You may regenerate the key on speedrun.com afterward if you prefer. We do not store + the key on our servers after verification. +

+
+
+
+ + +
+ +
+ {err ? ( +

+ {"message" in err && typeof err.message === "string" ? err.message : "Request failed"} +

+ ) : null} +
+ ) +}) diff --git a/src/lib/server/auth/authEvents.ts b/src/lib/server/auth/authEvents.ts new file mode 100644 index 00000000..9a77a1a7 --- /dev/null +++ b/src/lib/server/auth/authEvents.ts @@ -0,0 +1,25 @@ +import "server-only" + +import type { Account, User } from "@auth/core/types" +import type { AdapterUser } from "@auth/core/adapters" +import { pushLinkedRoleMetadataForUser } from "~/lib/server/discord/pushLinkedRoleMetadata" + +/** + * After Discord is linked to a Bungie user, push application role connection metadata once + * so linked roles can evaluate without waiting for a raid completion or manual Sync. + */ +export const authEvents = { + async linkAccount(message: { user: User | AdapterUser; account: Account }) { + if (message.account.provider !== "discord") { + return + } + const bungieMembershipId = message.user.id + if (typeof bungieMembershipId !== "string" || bungieMembershipId.length === 0) { + return + } + const result = await pushLinkedRoleMetadataForUser(bungieMembershipId) + if (!result.ok) { + console.warn("[authEvents.linkAccount] pushLinkedRoleMetadataForUser", result) + } + } +} diff --git a/src/lib/server/auth/discordTokenRefresh.ts b/src/lib/server/auth/discordTokenRefresh.ts new file mode 100644 index 00000000..b969309a --- /dev/null +++ b/src/lib/server/auth/discordTokenRefresh.ts @@ -0,0 +1,106 @@ +import "server-only" + +import { prisma } from "~/lib/server/prisma" +import { saferFetch } from "~/lib/server/saferFetch" + +type DiscordTokenResponse = { + access_token: string + refresh_token?: string + expires_in: number + scope?: string + token_type?: string +} + +const refreshInflight = new Map>() + +function needsAccessRefresh(expiresAt: number | null, accessToken: string | null, nowSec: number, skewSec: number): boolean { + if (!accessToken) return true + if (expiresAt == null) return true + return expiresAt - skewSec <= nowSec +} + +async function runRefreshDiscordAccountTokensIfNeeded(bungieMembershipId: string): Promise { + const account = await prisma.account.findFirst({ + where: { userId: bungieMembershipId, provider: "discord" }, + select: { + refreshToken: true, + accessToken: true, + expiresAt: true + } + }) + if (!account) { + return true + } + + const nowSec = Math.floor(Date.now() / 1000) + const skew = 300 + + if (!needsAccessRefresh(account.expiresAt, account.accessToken, nowSec, skew)) { + return true + } + + if (!account.refreshToken) { + return false + } + + const clientId = process.env.DISCORD_CLIENT_ID + const clientSecret = process.env.DISCORD_CLIENT_SECRET + if (!clientId || !clientSecret) { + return false + } + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: account.refreshToken + }) + + const res = await saferFetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }) + + const raw = (await res.json()) as DiscordTokenResponse & { error?: string; error_description?: string } + if (!res.ok) { + const code = typeof raw.error === "string" ? raw.error : "unknown" + console.warn("[DISCORD_TOKEN_REFRESH_HTTP_ERROR]", { status: res.status, error_code: code }) + return false + } + + const expiresAt = Math.floor(Date.now() / 1000) + (raw.expires_in ?? 604800) + + try { + await prisma.account.updateMany({ + where: { userId: bungieMembershipId, provider: "discord" }, + data: { + accessToken: raw.access_token, + refreshToken: raw.refresh_token ?? account.refreshToken, + expiresAt, + scope: raw.scope ?? undefined, + tokenType: raw.token_type ?? "bearer" + } + }) + } catch (e) { + console.error( + "[DISCORD_TOKEN_REFRESH_PERSIST_FAILED]", + e instanceof Error ? e.message : String(e) + ) + return false + } + return true +} + +/** Refreshes the Discord OAuth row for this Bungie user when near expiry. Returns false if a refresh was required but could not be completed. */ +export async function refreshDiscordAccountTokensIfNeeded(bungieMembershipId: string): Promise { + const existing = refreshInflight.get(bungieMembershipId) + if (existing) { + return existing + } + const p = runRefreshDiscordAccountTokensIfNeeded(bungieMembershipId).finally(() => { + refreshInflight.delete(bungieMembershipId) + }) + refreshInflight.set(bungieMembershipId, p) + return p +} diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index d7702732..4e75ba05 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -8,6 +8,7 @@ import TwitterProvider from "next-auth/providers/twitter" import { prisma } from "~/lib/server/prisma" import { reactRequestDedupe } from "~/util/react-cache" import { PrismaAdapter } from "./adapter" +import { authEvents } from "./authEvents" import BungieProvider from "./providers/bungie" import { YouTubeProvider } from "./providers/youtube" import { sessionCallback } from "./sessionCallback" @@ -35,6 +36,7 @@ const { session: sessionCallback, signIn: signInCallback }, + events: authEvents, logger: { error(err) { console.error(err) @@ -80,7 +82,8 @@ export function getProviders(): ProviderType[] { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET, // removes the email scope - authorization: "https://discord.com/api/oauth2/authorize?scope=identify" + authorization: + "https://discord.com/api/oauth2/authorize?scope=identify%20role_connections.write" }) providers.push(discordProvider) } diff --git a/src/lib/server/auth/sessionCallback.ts b/src/lib/server/auth/sessionCallback.ts index 03bac322..5a3a90db 100644 --- a/src/lib/server/auth/sessionCallback.ts +++ b/src/lib/server/auth/sessionCallback.ts @@ -7,6 +7,7 @@ import { prisma } from "~/lib/server/prisma" import { BungieServiceError } from "~/models/BungieAPIError" import ServerBungieClient from "~/services/bungie/ServerBungieClient" import { postRaidHubApi } from "~/services/raidhub/common" +import { refreshDiscordAccountTokensIfNeeded } from "./discordTokenRefresh" import { type AuthError, type BungieAccount } from "./types" import { updateBungieAccessTokens } from "./updateBungieAccessTokens" @@ -16,22 +17,29 @@ export const sessionCallback = (async ({ session, user: { raidHubAccessToken, bungieAccount, ...user } }: NonNullable["getSessionAndUser"]>>>) => { - const [bungieToken, raidhubToken] = await Promise.all([ + const [bungieToken, raidhubToken, discordRefreshOk] = await Promise.all([ refreshBungieAuth(bungieAccount, user.id), refreshRaidHubBearer({ userId: user.id, token: raidHubAccessToken, role: user.role, profiles: user.profiles - }) + }), + refreshDiscordAccountTokensIfNeeded(user.id) ]) + const errors: AuthError[] = [ + ...(raidhubToken?.errors ?? []), + ...bungieToken.errors, + ...(discordRefreshOk ? [] : (["DiscordTokenRefreshError"] as const)) + ] + return { user, primaryDestinyMembershipId: user.profiles.find(p => p.isPrimary)?.destinyMembershipId, bungieAccessToken: bungieToken.token, raidHubAccessToken: raidhubToken?.token ?? undefined, - errors: Array.from(new Set([...(raidhubToken?.errors ?? []), ...bungieToken.errors])), + errors: Array.from(new Set(errors)), expires: session.expires } }) as unknown as Required["callbacks"]["session"] diff --git a/src/lib/server/auth/types.ts b/src/lib/server/auth/types.ts index 3807c418..975aa70b 100644 --- a/src/lib/server/auth/types.ts +++ b/src/lib/server/auth/types.ts @@ -75,5 +75,7 @@ export type AuthError = | "BungieAccessTokenError" | "BungieAPIOffline" | "ExpiredBungieRefreshToken" + | "ExpiredRefreshTokenError" | "RaidHubAuthorizationError" | "PrismaError" + | "DiscordTokenRefreshError" diff --git a/src/lib/server/discord/linkedRoleSyncError.ts b/src/lib/server/discord/linkedRoleSyncError.ts new file mode 100644 index 00000000..3249f5a3 --- /dev/null +++ b/src/lib/server/discord/linkedRoleSyncError.ts @@ -0,0 +1,19 @@ +import "server-only" + +/** Only expose stable worker-written codes to the client (avoid leaking HTTP bodies or stack text from Turso). */ +export function sanitizeLinkedRoleSyncErrorCode(raw: string | null | undefined): string | null { + if (raw == null) { + return null + } + const t = raw.trim() + if (t.length === 0) { + return null + } + if (t.length > 64) { + return "sync_error" + } + if (!/^[\w.-]+$/.test(t)) { + return "sync_error" + } + return t +} diff --git a/src/lib/server/discord/pushLinkedRoleMetadata.ts b/src/lib/server/discord/pushLinkedRoleMetadata.ts new file mode 100644 index 00000000..6f037f06 --- /dev/null +++ b/src/lib/server/discord/pushLinkedRoleMetadata.ts @@ -0,0 +1,79 @@ +import "server-only" + +import { prisma } from "~/lib/server/prisma" +import { refreshDiscordAccountTokensIfNeeded } from "~/lib/server/auth/discordTokenRefresh" +import { postRaidHubApi } from "~/services/raidhub/common" +import { RAIDHUB_INTERNAL_PATHS } from "~/services/raidhub/internalPaths" +import { getRaidHubErrorEnvelopeMessage, RaidHubError } from "~/services/raidhub/RaidHubError" + +export type PushLinkedRoleMetadataResult = + | { ok: true } + | { + ok: false + code: + | "not_linked" + | "missing_env" + | "refresh_failed" + | "no_profile" + | "enqueue_failed" + detail?: string + } + +/** Validates Discord link, loads all Destiny profiles in Prisma, refreshes OAuth, then enqueues sync via api.raidhub.io → Rabbit → Hermes. */ +export async function pushLinkedRoleMetadataForUser(bungieMembershipId: string): Promise { + const apiUrl = process.env.RAIDHUB_API_URL?.trim() + const clientSecret = process.env.RAIDHUB_CLIENT_SECRET?.trim() + if (!apiUrl || !clientSecret) { + return { + ok: false, + code: "missing_env", + detail: "RAIDHUB_API_URL and RAIDHUB_CLIENT_SECRET (same value API uses as CLIENT_SECRET)" + } + } + + const discordRow = await prisma.account.findFirst({ + where: { userId: bungieMembershipId, provider: "discord" }, + select: { accessToken: true, scope: true } + }) + if (!discordRow?.accessToken) { + return { ok: false, code: "not_linked" } + } + if (!discordRow.scope?.includes("role_connections.write")) { + return { ok: false, code: "not_linked", detail: "reconnect_discord" } + } + + const profiles = await prisma.profile.findMany({ + where: { bungieMembershipId }, + select: { destinyMembershipId: true } + }) + if (profiles.length === 0) { + return { ok: false, code: "no_profile" } + } + const destinyMembershipIds = profiles.map(p => String(p.destinyMembershipId)) + + const refreshed = await refreshDiscordAccountTokensIfNeeded(bungieMembershipId) + if (!refreshed) { + return { ok: false, code: "refresh_failed" } + } + + try { + await postRaidHubApi( + RAIDHUB_INTERNAL_PATHS.queueDiscordLinkedRoleSync, + "post", + { destinyMembershipIds }, + null, + undefined, + { headers: { "x-raidhub-client-secret": clientSecret } } + ) + return { ok: true } + } catch (e) { + if (e instanceof RaidHubError && e.errorCode === "ServiceUnavailableError") { + return { ok: false, code: "enqueue_failed", detail: getRaidHubErrorEnvelopeMessage(e) } + } + return { + ok: false, + code: "enqueue_failed", + detail: "request_failed" + } + } +} diff --git a/src/lib/server/trpc/error-handler.ts b/src/lib/server/trpc/error-handler.ts index 3716d447..cc83b908 100644 --- a/src/lib/server/trpc/error-handler.ts +++ b/src/lib/server/trpc/error-handler.ts @@ -1,11 +1,8 @@ import type { ProcedureType, TRPCError } from "@trpc/server" -import { DiscordColors, sendDiscordWebhook } from "~/services/discord/webhook" export const trpcErrorHandler = async ({ error, - path, - input, - source + path }: { error: TRPCError type: ProcedureType | "unknown" @@ -14,54 +11,4 @@ export const trpcErrorHandler = async ({ source: "rpc" | "http" }) => { console.error(`❌ tRPC failed on ${path ?? ""}:`, error) - - if (process.env.NODE_ENV === "production" && process.env.TRPC_ALERTS_WEBHOOK_URL) { - await sendDiscordWebhook(process.env.TRPC_ALERTS_WEBHOOK_URL, { - embeds: [ - { - color: DiscordColors.RED, - fields: [ - { - name: error.cause?.constructor.name ?? error.name, - value: error.cause?.message ?? error.message, - inline: false - }, - { - name: "Path", - value: `\`${path}\``, - inline: false - }, - { - name: "Input", - value: `\`\`\`json\n${JSON.stringify(input ?? {}, null, 2).slice( - 0, - 1006 - )}\`\`\``, - inline: false - }, - { - name: "Stack Trace", - value: - error.stack - ?.split("\n") - .slice(1, 5) - .map(line => `\`\`\`${line.trim().replaceAll("at ", "")}\`\`\``) - .join("") ?? "", - inline: false - }, - { - name: "Source", - value: source, - inline: false - }, - { - name: "App Version", - value: `\`${process.env.APP_VERSION ?? "N/A"}\``, - inline: false - } - ] - } - ] - }) - } } diff --git a/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts b/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts new file mode 100644 index 00000000..62cf4a9a --- /dev/null +++ b/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts @@ -0,0 +1,77 @@ +import { sanitizeLinkedRoleSyncErrorCode } from "~/lib/server/discord/linkedRoleSyncError" +import { protectedProcedure } from "../.." + +type SyncHealth = + | "not_linked" + | "needs_scope" + | "needs_reconnect" + | "pending" + | "ok" + | "error" + +function deriveSyncHealth(input: { + linked: boolean + roleConnectionsScopeGranted: boolean + lastSyncedAt: string | null + lastError: string | null +}): SyncHealth { + if (!input.linked) { + return "not_linked" + } + if (!input.roleConnectionsScopeGranted) { + return "needs_scope" + } + if (input.lastError === "http_401" || input.lastError === "http_403") { + return "needs_reconnect" + } + if (input.lastError) { + return "error" + } + if (!input.lastSyncedAt) { + return "pending" + } + return "ok" +} + +export const discordLinkedRolesStatus = protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id + + const row = await ctx.prisma.account.findFirst({ + where: { userId, provider: "discord" }, + select: { + displayName: true, + scope: true, + discordRoleMetadataSyncedAt: true, + discordRoleMetadataSyncError: true + } + }) + + if (!row) { + return { + linked: false as const, + discordUsername: null, + roleConnectionsScopeGranted: false, + lastSyncedAt: null, + lastError: null, + syncHealth: "not_linked" as const + } + } + + const roleConnectionsScopeGranted = row.scope?.includes("role_connections.write") ?? false + const lastSyncedAt = row.discordRoleMetadataSyncedAt?.toISOString() ?? null + const lastError = sanitizeLinkedRoleSyncErrorCode(row.discordRoleMetadataSyncError) + + return { + linked: true as const, + discordUsername: row.displayName, + roleConnectionsScopeGranted, + lastSyncedAt, + lastError, + syncHealth: deriveSyncHealth({ + linked: true, + roleConnectionsScopeGranted, + lastSyncedAt, + lastError + }) + } +}) diff --git a/src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts b/src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts new file mode 100644 index 00000000..57b7c10d --- /dev/null +++ b/src/lib/server/trpc/procedures/user/pushDiscordLinkedRoles.ts @@ -0,0 +1,10 @@ +import { pushLinkedRoleMetadataForUser } from "~/lib/server/discord/pushLinkedRoleMetadata" +import { protectedProcedure } from "../.." + +export const pushDiscordLinkedRoles = protectedProcedure.mutation(async ({ ctx }) => { + const result = await pushLinkedRoleMetadataForUser(ctx.session.user.id) + if (result.ok) { + return { ok: true as const } + } + return { ok: false as const, code: result.code, detail: result.detail } +}) diff --git a/src/lib/server/trpc/router.ts b/src/lib/server/trpc/router.ts index 09db458c..389a181d 100644 --- a/src/lib/server/trpc/router.ts +++ b/src/lib/server/trpc/router.ts @@ -15,7 +15,9 @@ import { createPresignedProfilePicURL } from "./procedures/user/account/createPr import { removeProvider } from "./procedures/user/account/removeProvider" import { addByAPIKey } from "./procedures/user/account/speedrun-com/addByAPIKey" import { deleteUser } from "./procedures/user/delete" +import { discordLinkedRolesStatus } from "./procedures/user/discordLinkedRolesStatus" import { getConnections } from "./procedures/user/getConnections" +import { pushDiscordLinkedRoles } from "./procedures/user/pushDiscordLinkedRoles" import { getPrimaryAuthenticatedProfile } from "./procedures/user/getPrimaryAuthenticatedProfile" import { updateProfile } from "./procedures/user/updateProfile" import { updateUser } from "./procedures/user/updateUser" @@ -27,6 +29,8 @@ export const appRouter = createTRPCRouter({ getConnections: getConnections, getPrimaryProfile: getPrimaryAuthenticatedProfile, + discordLinkedRolesStatus, + pushDiscordLinkedRoles, update: updateUser, updateProfile: updateProfile, diff --git a/src/services/discord/webhook.ts b/src/services/discord/webhook.ts deleted file mode 100644 index ecfa0202..00000000 --- a/src/services/discord/webhook.ts +++ /dev/null @@ -1,43 +0,0 @@ -import "server-only" -import { saferFetch } from "~/lib/server/saferFetch" - -export interface DiscordWebhookData { - embeds: [ - { - color?: number - title?: string - description?: string - fields?: { - name: string - value: string - inline: boolean - }[] - } - ] -} - -export enum DiscordColors { - RED = 0xef0c09 -} - -export const sendDiscordWebhook = async (url: string, data: DiscordWebhookData) => { - const webhookResponse = await saferFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - embeds: data.embeds.slice(0, 10).map(embed => ({ - ...embed, - fields: embed.fields?.slice(0, 25).map(field => ({ - ...field, - name: field.name.slice(0, 256), - value: field.value.slice(0, 1024) - })) - })) - }) - }) - if (!webhookResponse.ok) { - throw new Error(`[${webhookResponse.status}] ${await webhookResponse.text()}`) - } -} diff --git a/src/services/raidhub/RaidHubError.ts b/src/services/raidhub/RaidHubError.ts index b3368dae..60bcbbde 100644 --- a/src/services/raidhub/RaidHubError.ts +++ b/src/services/raidhub/RaidHubError.ts @@ -14,3 +14,15 @@ export class RaidHubError extends Error { this.cause = res.error } } + +/** When the API returns an error envelope with a `message` field (e.g. ServiceUnavailable), surface it for BFF callers. */ +export function getRaidHubErrorEnvelopeMessage(error: RaidHubError): string | undefined { + const c = error.cause + if (c && typeof c === "object" && "message" in c) { + const m = (c as { message: unknown }).message + if (typeof m === "string" && m.trim().length > 0) { + return m + } + } + return undefined +} diff --git a/src/services/raidhub/common.ts b/src/services/raidhub/common.ts index e923a07f..ef639fac 100644 --- a/src/services/raidhub/common.ts +++ b/src/services/raidhub/common.ts @@ -7,6 +7,26 @@ import type { import { RaidHubError } from "./RaidHubError" import type { paths } from "./openapi" +/** openapi-typescript uses `readonly` on `requestBody` / `application/json`. */ +type RequestJsonBody< + T extends keyof paths, + M extends keyof paths[T] +> = paths[T][M] extends { readonly requestBody: infer RB } + ? RB extends { readonly content: infer C } + ? C extends { readonly "application/json": infer B } + ? B + : C extends { "application/json": infer B } + ? B + : never + : RB extends { content: infer C } + ? C extends { readonly "application/json": infer B } + ? B + : C extends { "application/json": infer B } + ? B + : never + : never + : never + export async function getRaidHubApi< T extends RaidHubGetPath, P = "parameters" extends keyof paths[T]["get"] ? paths[T]["get"]["parameters"] : null, @@ -65,13 +85,7 @@ export async function postRaidHubApi< >( path: T, method: M, - body: "requestBody" extends keyof paths[T][M] - ? "content" extends keyof paths[T][M]["requestBody"] - ? "application/json" extends keyof paths[T][M]["requestBody"]["content"] - ? paths[T][M]["requestBody"]["content"]["application/json"] - : never - : never - : never, + body: "requestBody" extends keyof paths[T][M] ? RequestJsonBody : never, pathParams: "path" extends keyof P ? P["path"] : null, queryParams?: "query" extends keyof P ? P["query"] : null, config?: Omit, diff --git a/src/services/raidhub/internalPaths.ts b/src/services/raidhub/internalPaths.ts new file mode 100644 index 00000000..2ee820da --- /dev/null +++ b/src/services/raidhub/internalPaths.ts @@ -0,0 +1,24 @@ +import type { paths } from "./openapi" +import type { RaidHubPostPath } from "./types" + +/** OpenAPI `paths` keys for BFF calls under ``/internal/*``. */ +export type RaidHubInternalPath = Extract + +/** + * Typed route strings (per-key literals so ``postRaidHubApi`` infers request bodies). + * Regenerate ``openapi.d.ts`` after API route changes; wrong strings fail at compile time + * when used with ``getRaidHubApi`` / ``postRaidHubApi``. + */ +export const RAIDHUB_INTERNAL_PATHS = { + queueDiscordLinkedRoleSync: "/internal/queue-discord-linked-role-sync", + subscriptionsDiscordWebhooks: "/internal/subscriptions/discord/webhooks" +} as const + +export type QueueDiscordLinkedRoleSyncRequestBody = + paths["/internal/queue-discord-linked-role-sync"]["post"]["requestBody"]["content"]["application/json"] + +export type DiscordSubscriptionWebhookPutBody = + paths["/internal/subscriptions/discord/webhooks"]["put"]["requestBody"]["content"]["application/json"] + +const _queueSyncPathIsPostable: RaidHubPostPath = RAIDHUB_INTERNAL_PATHS.queueDiscordLinkedRoleSync +void _queueSyncPathIsPostable diff --git a/src/services/raidhub/openapi.d.ts b/src/services/raidhub/openapi.d.ts index ce12a5de..85d5948c 100644 --- a/src/services/raidhub/openapi.d.ts +++ b/src/services/raidhub/openapi.d.ts @@ -107,6 +107,20 @@ export interface paths { }; }; }; + /** @description ServiceUnavailableError */ + 503: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ServiceUnavailableError"; + readonly error: components["schemas"]["ServiceUnavailableError"]; + }; + }; + }; }; }; }; @@ -121,6 +135,7 @@ export interface paths { parameters: { query: { count?: number; + offset?: number | null; query: string; membershipType?: components["schemas"]["DestinyMembershipType"]; global?: boolean; @@ -1099,6 +1114,14 @@ export interface paths { 400: { content: { readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidActivityVersionComboError"; + readonly error: components["schemas"]["InvalidActivityVersionComboError"]; + } | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -1134,14 +1157,6 @@ export interface paths { /** @enum {string} */ readonly code: "PlayerNotOnLeaderboardError"; readonly error: components["schemas"]["PlayerNotOnLeaderboardError"]; - } | { - /** Format: date-time */ - readonly minted: string; - /** @enum {boolean} */ - readonly success: false; - /** @enum {string} */ - readonly code: "InvalidActivityVersionComboError"; - readonly error: components["schemas"]["InvalidActivityVersionComboError"]; } | { /** Format: date-time */ readonly minted: string; @@ -1516,6 +1531,97 @@ export interface paths { }; }; }; + "/clan/{groupId}/basic": { + /** + * /clan/{groupId}/basic + * @description Low-cost clan identity (name, tag, avatar path) for bots and UIs. Does not load member rosters. + */ + get: { + parameters: { + path: { + groupId: string; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["ClanBasicResponse"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description Not found */ + 404: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ClanNotFoundError"; + readonly error: components["schemas"]["ClanNotFoundError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PathValidationError"; + readonly error: components["schemas"]["PathValidationError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + /** @description BungieServiceOffline */ + 503: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BungieServiceOffline"; + readonly error: components["schemas"]["BungieServiceOffline"]; + }; + }; + }; + }; + }; + }; "/metrics/weapons/rolling-week": { /** * /metrics/weapons/rolling-week @@ -1742,7 +1848,7 @@ export interface paths { "/admin/reporting/standing/{instanceId}": { /** * /admin/reporting/standing/{instanceId} - * @description Find a set of instances based on the query parameters. Some parameters will not work together, such as providing a season outside the range of the min/max season. Requires authentication. + * @description Get the standing information for a specific instance, including flags, blacklist status, and per-player standing data. */ get: { parameters: { @@ -1777,21 +1883,32 @@ export interface paths { }; }; }; + /** @description Forbidden */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; /** @description Not found */ 404: { content: { - readonly "application/json": ({ + readonly "application/json": { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: false; /** @enum {string} */ readonly code: "InstanceNotFoundError"; - readonly error: components["schemas"]["InstanceNotFoundError"] & { - /** Format: int64 */ - readonly instanceId?: string; - }; - }) | { + readonly error: components["schemas"]["InstanceNotFoundError"]; + } | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -1893,18 +2010,34 @@ export interface paths { }; }; }; + /** @description Forbidden */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; /** @description Not found */ 404: { content: { - readonly "application/json": { + readonly "application/json": ({ /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: false; /** @enum {string} */ readonly code: "InstanceNotFoundError"; - readonly error: components["schemas"]["InstanceNotFoundError"]; - } | { + readonly error: components["schemas"]["InstanceNotFoundError"] & { + readonly instanceId?: string; + }; + }) | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -1935,21 +2068,14 @@ export interface paths { "/admin/reporting/player/{membershipId}": { /** * /admin/reporting/player/{membershipId} - * @description Update fields on a player. Currently, only the cheat level can be updated. + * @description Get a player's standing information including recent flags and blacklisted instances. Requires authentication. */ - patch: { + get: { parameters: { path: { membershipId: string; }; }; - readonly requestBody: { - readonly content: { - readonly "application/json": { - readonly cheatLevel?: components["schemas"]["CheatLevel"]; - }; - }; - }; responses: { /** @description Success */ 200: { @@ -1963,8 +2089,8 @@ export interface paths { }; }; }; - /** @description Bad request */ - 400: { + /** @description Unauthorized */ + 401: { content: { readonly "application/json": { /** Format: date-time */ @@ -1972,13 +2098,13 @@ export interface paths { /** @enum {boolean} */ readonly success: false; /** @enum {string} */ - readonly code: "BodyValidationError"; - readonly error: components["schemas"]["BodyValidationError"]; + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; }; }; }; - /** @description Unauthorized */ - 401: { + /** @description Forbidden */ + 403: { content: { readonly "application/json": { /** Format: date-time */ @@ -1986,26 +2112,23 @@ export interface paths { /** @enum {boolean} */ readonly success: false; /** @enum {string} */ - readonly code: "ApiKeyError"; - readonly error: components["schemas"]["ApiKeyError"]; + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; }; }; }; /** @description Not found */ 404: { content: { - readonly "application/json": ({ + readonly "application/json": { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: false; /** @enum {string} */ readonly code: "PlayerNotFoundError"; - readonly error: components["schemas"]["PlayerNotFoundError"] & { - /** Format: int64 */ - readonly membershipId?: string; - }; - }) | { + readonly error: components["schemas"]["PlayerNotFoundError"]; + } | { /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ @@ -2032,18 +2155,20 @@ export interface paths { }; }; }; - }; - "/authorize/admin": { /** - * /authorize/admin - * @description Authorize an admin user. Requires the client secret. + * /admin/reporting/player/{membershipId} + * @description Update fields on a player. Currently, only the cheat level can be updated. */ - post: { + patch: { + parameters: { + path: { + membershipId: string; + }; + }; readonly requestBody: { readonly content: { readonly "application/json": { - readonly bungieMembershipId: string; - readonly adminClientSecret: string; + readonly cheatLevel?: components["schemas"]["CheatLevel"]; }; }; }; @@ -2056,7 +2181,7 @@ export interface paths { readonly minted: string; /** @enum {boolean} */ readonly success: true; - readonly response: components["schemas"]["AuthorizeAdminResponse"]; + readonly response: components["schemas"]["AdminReportingPlayerResponse"] & string; }; }; }; @@ -2088,7 +2213,7 @@ export interface paths { }; }; }; - /** @description InvalidClientSecretError */ + /** @description Forbidden */ 403: { content: { readonly "application/json": { @@ -2097,8 +2222,30 @@ export interface paths { /** @enum {boolean} */ readonly success: false; /** @enum {string} */ - readonly code: "InvalidClientSecretError"; - readonly error: components["schemas"]["InvalidClientSecretError"]; + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Not found */ + 404: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PlayerNotFoundError"; + readonly error: components["schemas"]["PlayerNotFoundError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PathValidationError"; + readonly error: components["schemas"]["PathValidationError"]; }; }; }; @@ -2119,18 +2266,17 @@ export interface paths { }; }; }; - "/authorize/user": { + "/authorize/admin": { /** - * /authorize/user - * @description Authenticate a user. Grants permission to access restricted resources. + * /authorize/admin + * @description Authorize an admin user. Requires the client secret. */ post: { readonly requestBody: { readonly content: { readonly "application/json": { readonly bungieMembershipId: string; - readonly destinyMembershipIds: readonly string[]; - readonly clientSecret: string; + readonly adminClientSecret: string; }; }; }; @@ -2143,7 +2289,7 @@ export interface paths { readonly minted: string; /** @enum {boolean} */ readonly success: true; - readonly response: components["schemas"]["AuthorizeUserResponse"]; + readonly response: components["schemas"]["AuthorizeAdminResponse"]; }; }; }; @@ -2206,16 +2352,454 @@ export interface paths { }; }; }; -} - -export type webhooks = Record; - -export interface components { - schemas: { - /** @enum {string} */ - readonly ErrorCode: "ApiKeyError" | "PathValidationError" | "QueryValidationError" | "BodyValidationError" | "PlayerNotFoundError" | "PlayerPrivateProfileError" | "PlayerProtectedResourceError" | "InstanceNotFoundError" | "PGCRNotFoundError" | "PlayerNotOnLeaderboardError" | "PlayerNotInInstance" | "RaidNotFoundError" | "PantheonVersionNotFoundError" | "InvalidActivityVersionComboError" | "ClanNotFoundError" | "AdminQuerySyntaxError" | "InsufficientPermissionsError" | "InvalidClientSecretError" | "InternalServerError" | "BungieServiceOffline"; - readonly RaidHubResponse: OneOf<[{ - /** Format: date-time */ + "/authorize/user": { + /** + * /authorize/user + * @description Authenticate a user. Grants permission to access restricted resources. + */ + post: { + readonly requestBody: { + readonly content: { + readonly "application/json": { + readonly bungieMembershipId: string; + readonly destinyMembershipIds: readonly string[]; + readonly clientSecret: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["AuthorizeUserResponse"]; + }; + }; + }; + /** @description Bad request */ + 400: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BodyValidationError"; + readonly error: components["schemas"]["BodyValidationError"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InvalidClientSecretError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidClientSecretError"; + readonly error: components["schemas"]["InvalidClientSecretError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + }; + "/internal/queue-discord-linked-role-sync": { + /** + * /internal/queue-discord-linked-role-sync + * @description Queue a Discord linked-role metadata sync. Body: Destiny membership ids only. Send `x-raidhub-client-secret: ` (not in JSON). Refresh Discord OAuth in the BFF before calling. + */ + post: { + readonly requestBody: { + readonly content: { + readonly "application/json": { + readonly destinyMembershipIds: readonly string[]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalQueueDiscordLinkedRoleSyncResponse"]; + }; + }; + }; + /** @description Bad request */ + 400: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BodyValidationError"; + readonly error: components["schemas"]["BodyValidationError"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InvalidClientSecretError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidClientSecretError"; + readonly error: components["schemas"]["InvalidClientSecretError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + /** @description ServiceUnavailableError */ + 503: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ServiceUnavailableError"; + readonly error: components["schemas"]["ServiceUnavailableError"]; + }; + }; + }; + }; + }; + }; + "/internal/subscriptions/discord/webhooks": { + /** + * /internal/subscriptions/discord/webhooks + * @description Get RaidHub subscription webhook status for the current channel (no secrets). + */ + get: { + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalSubscriptionsDiscordWebhooksResponse"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidDiscordAuthError"; + readonly error: components["schemas"]["InvalidDiscordAuthError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InsufficientPermissionsError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + /** + * /internal/subscriptions/discord/webhooks + * @description Create or update the RaidHub subscription webhook for this channel (idempotent upsert). + */ + put: { + readonly requestBody: { + readonly content: { + readonly "application/json": components["schemas"]["DiscordWebhookBody"]; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalSubscriptionsDiscordWebhooksResponse"] & { + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + /** Format: uri */ + readonly webhookUrl?: string; + readonly created: boolean; + readonly activated: boolean; + readonly updated: boolean; + readonly rules: { + readonly players: { + readonly inserted: number; + readonly updated: number; + }; + readonly clans: { + readonly inserted: number; + readonly updated: number; + }; + }; + }; + }; + }; + }; + /** @description Bad request */ + 400: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "BodyValidationError"; + readonly error: components["schemas"]["BodyValidationError"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidDiscordAuthError"; + readonly error: components["schemas"]["InvalidDiscordAuthError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InsufficientPermissionsError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + /** + * /internal/subscriptions/discord/webhooks + * @description Delete a Discord subscription webhook registration for the current channel. + */ + delete: { + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["InternalSubscriptionsDiscordWebhooksResponse"] & { + readonly deleted: boolean; + }; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InvalidDiscordAuthError"; + readonly error: components["schemas"]["InvalidDiscordAuthError"]; + } | { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description InsufficientPermissionsError */ + 403: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InsufficientPermissionsError"; + readonly error: components["schemas"]["InsufficientPermissionsError"]; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + }; +} + +export type webhooks = Record; + +export interface components { + schemas: { + /** @enum {string} */ + readonly ErrorCode: "ApiKeyError" | "PathValidationError" | "QueryValidationError" | "BodyValidationError" | "PlayerNotFoundError" | "PlayerPrivateProfileError" | "PlayerProtectedResourceError" | "InstanceNotFoundError" | "PGCRNotFoundError" | "PlayerNotOnLeaderboardError" | "PlayerNotInInstance" | "RaidNotFoundError" | "PantheonVersionNotFoundError" | "InvalidActivityVersionComboError" | "ClanNotFoundError" | "AdminQuerySyntaxError" | "InsufficientPermissionsError" | "InvalidClientSecretError" | "InvalidDiscordAuthError" | "InternalServerError" | "ServiceUnavailableError" | "BungieServiceOffline"; + readonly RaidHubResponse: OneOf<[{ + /** Format: date-time */ readonly minted: string; /** @enum {boolean} */ readonly success: true; @@ -2281,7 +2865,7 @@ export interface components { readonly versionId: number; /** @description If the instance was completed before the day one end date */ readonly isDayOne: boolean; - /** @description If the instance was completed before the contest end date */ + /** @description If this clear was contest mode: when the activity exposes version_id 32 (contest) on activity_version, true only for that version while still before contest_end; otherwise true when completed before contest_end (legacy raids). */ readonly isContest: boolean; /** @description If the instance was completed before the week one end date */ readonly isWeekOne: boolean; @@ -2337,7 +2921,7 @@ export interface components { /** Format: int64 */ readonly membershipId: string; /** @description The platform on which the player created their account. */ - readonly membershipType: components["schemas"]["DestinyMembershipType"] | null; + readonly membershipType: components["schemas"]["DestinyMembershipType"]; readonly iconPath: string | null; /** @description The platform-specific display name of the player. No longer shown in-game. */ readonly displayName: string | null; @@ -2445,6 +3029,8 @@ export interface components { readonly instanceId: string; /** Format: int64 */ readonly membershipId: string; + /** Format: date-time */ + readonly instanceDate: string; }; readonly InstancePlayerStanding: { readonly playerInfo: components["schemas"]["PlayerInfo"]; @@ -2464,6 +3050,24 @@ export interface components { })[]; readonly otherRecentFlags: readonly components["schemas"]["InstancePlayerFlag"][]; }; + readonly PlayerBlacklistedInstance: { + /** Format: int64 */ + readonly instanceId: string; + /** Format: date-time */ + readonly instanceDate: string; + readonly reason: string; + readonly individualReason: string | null; + /** Format: date-time */ + readonly createdAt: string; + }; + readonly ClanBasic: { + /** Format: int64 */ + readonly groupId: string; + readonly name: string; + readonly callSign: string; + readonly motto: string; + readonly avatarPath: string | null; + }; readonly ClanBannerData: { readonly decalId: number; readonly decalColorId: number; @@ -2527,13 +3131,6 @@ export interface components { readonly totalTimePlayedSeconds: number; readonly contestScore: number; }; - readonly ClanStats: { - readonly aggregateStats: components["schemas"]["ClanAggregateStats"]; - readonly members: readonly ({ - readonly playerInfo: components["schemas"]["PlayerInfo"] | null; - readonly stats: components["schemas"]["ClanMemberStats"]; - })[]; - }; readonly InstanceMetadata: { readonly activityName: string; readonly versionName: string; @@ -2575,11 +3172,78 @@ export interface components { readonly playerInfo: components["schemas"]["PlayerInfo"]; readonly characters: readonly components["schemas"]["InstanceCharacter"][]; }; - readonly InstanceExtended: components["schemas"]["Instance"] & ({ - readonly leaderboardRank: number | null; - readonly metadata: components["schemas"]["InstanceMetadata"]; - readonly players: readonly components["schemas"]["InstancePlayerExtended"][]; - }); + /** @default {} */ + readonly DiscordWebhookBody: { + readonly name?: string; + readonly targets?: { + readonly players?: readonly { + readonly membershipId: string; + readonly requireFresh?: boolean; + readonly requireCompleted?: boolean; + readonly raids?: readonly number[]; + }[]; + readonly clans?: readonly { + readonly groupId: string; + readonly requireFresh?: boolean; + readonly requireCompleted?: boolean; + readonly raids?: readonly number[]; + }[]; + }; + }; + readonly DiscordWebhookPutResponse: { + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + /** Format: uri */ + readonly webhookUrl?: string; + readonly created: boolean; + readonly activated: boolean; + readonly updated: boolean; + readonly rules: { + readonly players: { + readonly inserted: number; + readonly updated: number; + }; + readonly clans: { + readonly inserted: number; + readonly updated: number; + }; + }; + }; + readonly DiscordWebhookDeleteResponse: { + readonly deleted: boolean; + }; + readonly DiscordWebhookStatusResponse: OneOf<[{ + /** @enum {boolean} */ + readonly registered: false; + }, { + /** @enum {boolean} */ + readonly registered: true; + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + readonly destinationActive: boolean; + readonly consecutiveDeliveryFailures: number; + readonly lastDeliverySuccessAt: string | null; + readonly lastDeliveryFailureAt: string | null; + readonly lastDeliveryError: string | null; + readonly players: readonly { + readonly membershipId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + readonly clans: readonly { + readonly groupId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + }]>; + readonly InvalidDiscordAuthError: { + /** @enum {string} */ + readonly message: "Invalid Discord context token"; + }; readonly TeamLeaderboardEntry: { readonly position: number; readonly rank: number; @@ -2594,23 +3258,6 @@ export interface components { readonly value: number; readonly playerInfo: components["schemas"]["PlayerInfo"]; }; - readonly LeaderboardData: OneOf<[{ - /** @enum {string} */ - readonly type: "team"; - /** @enum {string} */ - readonly format: "duration" | "numerical"; - readonly page: number; - readonly count: number; - readonly entries: readonly components["schemas"]["TeamLeaderboardEntry"][]; - }, { - /** @enum {string} */ - readonly type: "individual"; - /** @enum {string} */ - readonly format: "duration" | "numerical"; - readonly page: number; - readonly count: number; - readonly entries: readonly components["schemas"]["IndividualLeaderboardEntry"][]; - }]>; /** @enum {string} */ readonly IndividualGlobalLeaderboardCategory: "clears" | "full-clears" | "sherpas" | "speedrun" | "world-first-rankings" | "in-raid-time"; /** @description Pagination parameters for leaderboard data */ @@ -2675,7 +3322,7 @@ export interface components { * @example medium * @enum {string} */ - readonly ImageSize: "tiny" | "small" | "medium" | "large" | "xlarge"; + readonly ImageSize: "tiny" | "small" | "medium" | "large" | "xlarge" | "full"; /** * @description A URL to a piece of content hosted on the RaidHub CDN. * @example { @@ -2714,92 +3361,15 @@ export interface components { readonly PopulationByRaidMetric: { [key: string]: number; }; - /** @description A raw PGCR with a few redundant fields removed */ - readonly RaidHubPostGameCarnageReport: { - /** Format: date-time */ - readonly period: string; - readonly startingPhaseIndex?: number; - readonly activityWasStartedFromBeginning?: boolean; - readonly activityDetails: { - /** Format: uint32 */ - readonly directorActivityHash: number; - /** Format: int64 */ - readonly instanceId: string; - /** @enum {integer} */ - readonly mode: 0 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91; - readonly modes: readonly (0 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91)[]; - readonly membershipType: components["schemas"]["DestinyMembershipType"]; - }; - readonly activityDifficultyTier?: number; - readonly selectedSkullHashes?: readonly number[]; - readonly entries: readonly ({ - readonly player: { - readonly destinyUserInfo: { - readonly iconPath?: string | null; - readonly crossSaveOverride: components["schemas"]["DestinyMembershipType"]; - readonly applicableMembershipTypes?: (readonly components["schemas"]["DestinyMembershipType"][]) | null; - readonly membershipType?: components["schemas"]["DestinyMembershipType"] | null; - readonly membershipId: string; - readonly displayName?: string | null; - readonly bungieGlobalDisplayName?: string | null; - readonly bungieGlobalDisplayNameCode?: number | null; - }; - readonly characterClass?: string | null; - /** Format: uint32 */ - readonly classHash: number; - /** Format: uint32 */ - readonly raceHash: number; - /** Format: uint32 */ - readonly genderHash: number; - readonly characterLevel: number; - readonly lightLevel: number; - /** Format: uint32 */ - readonly emblemHash: number; - }; - readonly characterId: string; - readonly values: { - [key: string]: { - readonly basic: { - readonly value: number; - readonly displayValue: string; - }; - }; - }; - readonly extended?: { - readonly weapons?: (readonly { - readonly referenceId: number; - readonly values: { - [key: string]: { - readonly basic: { - readonly value: number; - readonly displayValue: string; - }; - }; - }; - }[]) | null; - readonly values: { - [key: string]: { - readonly basic: { - readonly value: number; - readonly displayValue: string; - }; - }; - }; - }; - })[]; - }; readonly InstanceForPlayer: components["schemas"]["Instance"] & { readonly player: components["schemas"]["InstancePlayer"]; }; - readonly InstanceWithPlayers: components["schemas"]["Instance"] & { - readonly players: readonly components["schemas"]["PlayerInfo"][]; - }; readonly PlayerProfileActivityStats: { readonly activityId: number; readonly freshClears: number; readonly clears: number; readonly sherpas: number; - readonly fastestInstance: components["schemas"]["Instance"] | null; + readonly fastestInstance: components["schemas"]["Instance"]; }; readonly GlobalStat: { readonly value: number; @@ -2825,18 +3395,6 @@ export interface components { readonly isWeekOne: boolean; readonly isChallengeMode: boolean; }; - readonly PlayerProfile: { - readonly playerInfo: components["schemas"]["PlayerInfo"]; - readonly stats: { - readonly global: components["schemas"]["PlayerProfileGlobalStats"]; - readonly activity: { - [key: string]: components["schemas"]["PlayerProfileActivityStats"]; - }; - }; - readonly worldFirstEntries: { - [key: string]: components["schemas"]["WorldFirstEntry"] | null; - }; - }; readonly Teammate: { readonly estimatedTimePlayedSeconds: number; readonly clears: number; @@ -2864,7 +3422,7 @@ export interface components { readonly incomingRate: number; readonly resolveRate: number; readonly backlog: number; - readonly latestResolvedInstance: components["schemas"]["LatestResolvedInstance"] | null; + readonly latestResolvedInstance: components["schemas"]["LatestResolvedInstance"]; /** Format: date-time */ readonly estimatedBacklogEmptied: string | null; }; @@ -2920,23 +3478,31 @@ export interface components { readonly AtlasPGCR: components["schemas"]["AtlasStatus"]; readonly FloodgatesPGCR: components["schemas"]["FloodgatesStatus"]; }; + readonly ServiceUnavailableError: { + readonly serviceName: string; + readonly message: string; + }; readonly PlayerSearchResponse: { readonly params: { readonly count: number; + readonly offset: number; readonly query: string; }; readonly results: readonly components["schemas"]["PlayerInfo"][]; }; readonly PlayerHistoryResponse: { + /** Format: int64 */ readonly membershipId: string; /** Format: date-time */ readonly nextCursor: string | null; readonly activities: readonly components["schemas"]["InstanceForPlayer"][]; }; readonly PlayerNotFoundError: { + /** Format: int64 */ readonly membershipId: string; }; readonly PlayerPrivateProfileError: { + /** Format: int64 */ readonly membershipId: string; }; /** @@ -2956,7 +3522,7 @@ export interface components { /** Format: int64 */ readonly membershipId: string; /** @description The platform on which the player created their account. */ - readonly membershipType: components["schemas"]["DestinyMembershipType"] | null; + readonly membershipType: components["schemas"]["DestinyMembershipType"]; readonly iconPath: string | null; /** @description The platform-specific display name of the player. No longer shown in-game. */ readonly displayName: string | null; @@ -2977,13 +3543,16 @@ export interface components { }; }; readonly worldFirstEntries: { - [key: string]: components["schemas"]["WorldFirstEntry"] | null; + [key: string]: components["schemas"]["WorldFirstEntry"]; }; }; readonly PlayerTeammatesResponse: readonly components["schemas"]["Teammate"][]; - readonly PlayerInstancesResponse: readonly components["schemas"]["InstanceWithPlayers"][]; + readonly PlayerInstancesResponse: readonly (components["schemas"]["Instance"] & { + readonly players: readonly components["schemas"]["PlayerInfo"][]; + })[]; readonly PlayerProtectedResourceError: { readonly message: string; + /** Format: int64 */ readonly membershipId: string; }; readonly InstanceResponse: components["schemas"]["Instance"] & ({ @@ -2992,6 +3561,7 @@ export interface components { readonly players: readonly components["schemas"]["InstancePlayerExtended"][]; }); readonly InstanceNotFoundError: { + /** Format: int64 */ readonly instanceId: string; }; readonly LeaderboardIndividualGlobalResponse: OneOf<[{ @@ -3012,6 +3582,7 @@ export interface components { readonly entries: readonly components["schemas"]["IndividualLeaderboardEntry"][]; }]>; readonly PlayerNotOnLeaderboardError: { + /** Format: int64 */ readonly membershipId: string; }; readonly LeaderboardIndividualRaidResponse: OneOf<[{ @@ -3117,7 +3688,8 @@ export interface components { readonly iconPath?: string | null; readonly crossSaveOverride: components["schemas"]["DestinyMembershipType"]; readonly applicableMembershipTypes?: (readonly components["schemas"]["DestinyMembershipType"][]) | null; - readonly membershipType?: components["schemas"]["DestinyMembershipType"] | null; + readonly membershipType?: components["schemas"]["DestinyMembershipType"]; + /** Format: int64 */ readonly membershipId: string; readonly displayName?: string | null; readonly bungieGlobalDisplayName?: string | null; @@ -3135,6 +3707,7 @@ export interface components { /** Format: uint32 */ readonly emblemHash: number; }; + /** Format: int64 */ readonly characterId: string; readonly values: { [key: string]: { @@ -3168,22 +3741,32 @@ export interface components { })[]; }; readonly PGCRNotFoundError: { + /** Format: int64 */ readonly instanceId: string; }; readonly ClanResponse: { readonly aggregateStats: components["schemas"]["ClanAggregateStats"]; - readonly members: readonly ({ - readonly playerInfo: components["schemas"]["PlayerInfo"] | null; + readonly members: readonly { + readonly playerInfo: components["schemas"]["PlayerInfo"]; readonly stats: components["schemas"]["ClanMemberStats"]; - })[]; + }[]; }; readonly ClanNotFoundError: { + /** Format: int64 */ readonly groupId: string; }; readonly BungieServiceOffline: { readonly message: string; readonly route: string; }; + readonly ClanBasicResponse: { + /** Format: int64 */ + readonly groupId: string; + readonly name: string; + readonly callSign: string; + readonly motto: string; + readonly avatarPath: string | null; + }; readonly MetricsWeaponsRollingWeekResponse: { readonly energy: readonly components["schemas"]["WeaponMetric"][]; readonly kinetic: readonly components["schemas"]["WeaponMetric"][]; @@ -3219,7 +3802,7 @@ export interface components { }; readonly AdminReportingStandingResponse: { readonly instanceDetails: components["schemas"]["InstanceBasic"]; - readonly blacklist: components["schemas"]["InstanceBlacklist"] | null; + readonly blacklist: components["schemas"]["InstanceBlacklist"]; readonly flags: readonly components["schemas"]["InstanceFlag"][]; readonly players: readonly components["schemas"]["InstancePlayerStanding"][]; }; @@ -3227,10 +3810,15 @@ export interface components { readonly blacklisted: boolean; }; readonly PlayerNotInInstance: { + /** Format: int64 */ readonly instanceId: string; readonly players: readonly string[]; }; - readonly AdminReportingPlayerResponse: string; + readonly AdminReportingPlayerResponse: { + readonly playerInfo: components["schemas"]["PlayerInfo"]; + readonly recentFlags: readonly components["schemas"]["InstancePlayerFlag"][]; + readonly blacklistedInstances: readonly components["schemas"]["PlayerBlacklistedInstance"][]; + }; readonly AuthorizeAdminResponse: { readonly value: string; /** Format: date-time */ @@ -3242,6 +3830,38 @@ export interface components { /** Format: date-time */ readonly expires: string; }; + readonly InternalQueueDiscordLinkedRoleSyncResponse: { + /** @enum {boolean} */ + readonly queued: true; + readonly destinyMembershipIds: readonly string[]; + }; + readonly InternalSubscriptionsDiscordWebhooksResponse: OneOf<[{ + /** @enum {boolean} */ + readonly registered: false; + }, { + /** @enum {boolean} */ + readonly registered: true; + readonly guildId: string; + readonly channelId: string; + readonly webhookId: string; + readonly destinationActive: boolean; + readonly consecutiveDeliveryFailures: number; + readonly lastDeliverySuccessAt: string | null; + readonly lastDeliveryFailureAt: string | null; + readonly lastDeliveryError: string | null; + readonly players: readonly { + readonly membershipId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + readonly clans: readonly { + readonly groupId: string; + readonly requireFresh: boolean; + readonly requireCompleted: boolean; + readonly raidIds: readonly number[]; + }[]; + }]>; }; responses: never; parameters: { diff --git a/src/services/raidhub/types.ts b/src/services/raidhub/types.ts index 80586e0f..1ba960c1 100644 --- a/src/services/raidhub/types.ts +++ b/src/services/raidhub/types.ts @@ -45,8 +45,9 @@ export type RaidHubFeatDefinition = Component<"FeatDefinition"> export type RaidHubPlayerInfo = Component<"PlayerInfo"> export type RaidHubInstance = Component<"Instance"> -export type RaidHubInstanceExtended = Component<"InstanceExtended"> -export type RaidHubInstanceWithPlayers = Component<"InstanceWithPlayers"> +export type RaidHubInstanceExtended = Component<"InstanceResponse"> +/** One row from GET /player/{membershipId}/instances — `Instance` plus roster `players`. */ +export type RaidHubInstanceWithPlayers = components["schemas"]["PlayerInstancesResponse"][number] export type RaidHubInstancePlayerExtended = Component<"InstancePlayerExtended"> export type RaidHubInstanceCharacter = Component<"InstanceCharacter"> export type RaidHubInstanceForPlayer = Component<"InstanceForPlayer"> @@ -55,7 +56,13 @@ export type RaidHubClanMemberStats = Component<"ClanMemberStats"> export type RaidHubWeaponMetric = Component<"WeaponMetric"> -export type RaidHubLeaderboardData = Component<"LeaderboardData"> +/** Union of leaderboard GET responses that use team vs individual entries (excludes clan-only shape). */ +export type RaidHubLeaderboardData = + | components["schemas"]["LeaderboardIndividualGlobalResponse"] + | components["schemas"]["LeaderboardIndividualRaidResponse"] + | components["schemas"]["LeaderboardIndividualPantheonResponse"] + | components["schemas"]["LeaderboardTeamFirstResponse"] + | components["schemas"]["LeaderboardTeamContestResponse"] export type RaidHubIndividualLeaderboardEntry = Component<"IndividualLeaderboardEntry"> export type RaidHubLeaderboardURL = RaidHubGetPath & @@ -128,9 +135,10 @@ interface GetSchema { } interface PostSchema { - requestBody?: { - content: { - "application/json": unknown + /** openapi-ts marks `requestBody` readonly; must match for `RaidHubPostPath` / `KeysWhichValuesExtend`. */ + readonly requestBody?: { + readonly content: { + readonly "application/json": unknown } } parameters?: { @@ -139,7 +147,7 @@ interface PostSchema { } responses: { 200: { - content: { + readonly content: { readonly "application/json": unknown } } diff --git a/src/types/api.ts b/src/types/api.ts index a03ae98c..258ee9a1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,6 +3,9 @@ import { type AppRouter } from "~/lib/server/trpc" export type RouterOutput = inferRouterOutputs +/** tRPC `user.discordLinkedRolesStatus` — use from client UI instead of importing server procedure types. */ +export type DiscordLinkedRoleSyncHealth = RouterOutput["user"]["discordLinkedRolesStatus"]["syncHealth"] + export type AppProfile = RouterOutput["profile"]["getUnique"] export type AppUserUpdate = RouterOutput["user"]["update"] export type AppRole = "ADMIN" | "USER" From f3bd963c7d35fc1be800037a5c35ef01c431f6cc Mon Sep 17 00:00:00 2001 From: owen Date: Sun, 3 May 2026 23:08:21 -0400 Subject: [PATCH 4/4] chore(ci): prettier account and linked-role files Co-authored-by: Cursor --- prisma/schema.prisma | 36 +++++++++---------- .../account/AccountConnectionCard.tsx | 22 ++++++++---- src/components/account/AccountPage.tsx | 36 ++++++++++++------- .../account/DiscordLinkedRolesPanel.tsx | 21 +++++++---- src/components/account/ProfileIconForm.tsx | 18 ++++++---- .../account/SpeedrunAPIKeyDialog.tsx | 26 +++++++++----- src/lib/server/auth/authEvents.ts | 2 +- src/lib/server/auth/discordTokenRefresh.ts | 20 ++++++++--- .../server/discord/pushLinkedRoleMetadata.ts | 13 +++---- .../user/discordLinkedRolesStatus.ts | 8 +---- src/lib/server/trpc/router.ts | 2 +- src/services/raidhub/common.ts | 7 ++-- src/types/api.ts | 3 +- 13 files changed, 130 insertions(+), 84 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b8e2f497..a7f2268a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,26 +76,26 @@ model Session { } model Account { - id String @id @default(uuid()) - userId String @map("bungie_membership_id") - type String - provider String - providerAccountId String @map("provider_account_id") - displayName String? @map("display_name") - url String? @map("url") - refreshToken String? @map("refresh_token") - accessToken String? @map("access_token") - expiresAt Int? @map("expires_at") - refreshExpiresAt Int? @map("refresh_expires_at") - tokenType String? @map("token_type") - scope String? - idToken String? @map("id_token") - sessionState String? @map("session_state") + id String @id @default(uuid()) + userId String @map("bungie_membership_id") + type String + provider String + providerAccountId String @map("provider_account_id") + displayName String? @map("display_name") + url String? @map("url") + refreshToken String? @map("refresh_token") + accessToken String? @map("access_token") + expiresAt Int? @map("expires_at") + refreshExpiresAt Int? @map("refresh_expires_at") + tokenType String? @map("token_type") + scope String? + idToken String? @map("id_token") + sessionState String? @map("session_state") /// Last successful push of Discord linked-role metadata (Hermes or BFF). - discordRoleMetadataSyncedAt DateTime? @map("discord_role_metadata_synced_at") + discordRoleMetadataSyncedAt DateTime? @map("discord_role_metadata_synced_at") /// Short machine-readable error from last failed push (if any). - discordRoleMetadataSyncError String? @map("discord_role_metadata_sync_error") - user User @relation("UserToAccount", fields: [userId], references: [id], onDelete: Cascade) + discordRoleMetadataSyncError String? @map("discord_role_metadata_sync_error") + user User @relation("UserToAccount", fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId], name: "uniqueProviderAccountId") @@unique([provider, userId], name: "uniqueProviderUser") diff --git a/src/components/account/AccountConnectionCard.tsx b/src/components/account/AccountConnectionCard.tsx index 119bea2e..85f9a176 100644 --- a/src/components/account/AccountConnectionCard.tsx +++ b/src/components/account/AccountConnectionCard.tsx @@ -26,10 +26,9 @@ export function AccountConnectionCard({ const linked = Boolean(username) return ( - + -
+
@@ -37,7 +36,8 @@ export function AccountConnectionCard({ {linked ? ( <> - Linked as {username} + Linked as{" "} + {username} ) : ( "Not connected" @@ -47,10 +47,20 @@ export function AccountConnectionCard({
- -
diff --git a/src/components/account/AccountPage.tsx b/src/components/account/AccountPage.tsx index b95fcdda..b71464c4 100644 --- a/src/components/account/AccountPage.tsx +++ b/src/components/account/AccountPage.tsx @@ -1,10 +1,10 @@ "use client" import { type Collection } from "@discordjs/collection" -import Link from "next/link" -import { useMemo, useRef } from "react" import { type Session } from "next-auth" import { signIn, signOut } from "next-auth/react" +import Link from "next/link" +import { useMemo, useRef } from "react" import { DiscordIconOld } from "~/components/icons/DiscordIcon" import { SpeedrunIcon } from "~/components/icons/SpeedrunIcon" import TwitchIcon from "~/components/icons/TwitchIcon" @@ -86,14 +86,16 @@ export function AccountPage({ session, providers }: AccountPageProps) {
- +
{session.user.image ? ( ) : null} - {initial} + + {initial} +
@@ -101,8 +103,8 @@ export function AccountPage({ session, providers }: AccountPageProps) { {session.user.name} - Signed in with Bungie. Open a profile, tweak your icon, and link social - accounts below. + Signed in with Bungie. Open a profile, tweak your icon, and link + social accounts below.
@@ -111,10 +113,16 @@ export function AccountPage({ session, providers }: AccountPageProps) { bungieMembershipTypeLabel[profile.destinyMembershipType] ?? "Profile" return ( -
@@ -214,12 +223,15 @@ export function AccountPage({ session, providers }: AccountPageProps) {
-

Danger zone

+

+ Danger zone +

- Permanently delete your RaidHub account and associated data. This cannot be undone. + Permanently delete your RaidHub account and associated data. This cannot be + undone.

- +

Delete your RaidHub account

- +
{err ? (

- {"message" in err && typeof err.message === "string" ? err.message : "Request failed"} + {"message" in err && typeof err.message === "string" + ? err.message + : "Request failed"}

) : null} diff --git a/src/lib/server/auth/authEvents.ts b/src/lib/server/auth/authEvents.ts index 9a77a1a7..92790d87 100644 --- a/src/lib/server/auth/authEvents.ts +++ b/src/lib/server/auth/authEvents.ts @@ -1,7 +1,7 @@ import "server-only" -import type { Account, User } from "@auth/core/types" import type { AdapterUser } from "@auth/core/adapters" +import type { Account, User } from "@auth/core/types" import { pushLinkedRoleMetadataForUser } from "~/lib/server/discord/pushLinkedRoleMetadata" /** diff --git a/src/lib/server/auth/discordTokenRefresh.ts b/src/lib/server/auth/discordTokenRefresh.ts index b969309a..44b26b00 100644 --- a/src/lib/server/auth/discordTokenRefresh.ts +++ b/src/lib/server/auth/discordTokenRefresh.ts @@ -13,13 +13,20 @@ type DiscordTokenResponse = { const refreshInflight = new Map>() -function needsAccessRefresh(expiresAt: number | null, accessToken: string | null, nowSec: number, skewSec: number): boolean { +function needsAccessRefresh( + expiresAt: number | null, + accessToken: string | null, + nowSec: number, + skewSec: number +): boolean { if (!accessToken) return true if (expiresAt == null) return true return expiresAt - skewSec <= nowSec } -async function runRefreshDiscordAccountTokensIfNeeded(bungieMembershipId: string): Promise { +async function runRefreshDiscordAccountTokensIfNeeded( + bungieMembershipId: string +): Promise { const account = await prisma.account.findFirst({ where: { userId: bungieMembershipId, provider: "discord" }, select: { @@ -62,7 +69,10 @@ async function runRefreshDiscordAccountTokensIfNeeded(bungieMembershipId: string body }) - const raw = (await res.json()) as DiscordTokenResponse & { error?: string; error_description?: string } + const raw = (await res.json()) as DiscordTokenResponse & { + error?: string + error_description?: string + } if (!res.ok) { const code = typeof raw.error === "string" ? raw.error : "unknown" console.warn("[DISCORD_TOKEN_REFRESH_HTTP_ERROR]", { status: res.status, error_code: code }) @@ -93,7 +103,9 @@ async function runRefreshDiscordAccountTokensIfNeeded(bungieMembershipId: string } /** Refreshes the Discord OAuth row for this Bungie user when near expiry. Returns false if a refresh was required but could not be completed. */ -export async function refreshDiscordAccountTokensIfNeeded(bungieMembershipId: string): Promise { +export async function refreshDiscordAccountTokensIfNeeded( + bungieMembershipId: string +): Promise { const existing = refreshInflight.get(bungieMembershipId) if (existing) { return existing diff --git a/src/lib/server/discord/pushLinkedRoleMetadata.ts b/src/lib/server/discord/pushLinkedRoleMetadata.ts index 6f037f06..4ad0c178 100644 --- a/src/lib/server/discord/pushLinkedRoleMetadata.ts +++ b/src/lib/server/discord/pushLinkedRoleMetadata.ts @@ -1,7 +1,7 @@ import "server-only" -import { prisma } from "~/lib/server/prisma" import { refreshDiscordAccountTokensIfNeeded } from "~/lib/server/auth/discordTokenRefresh" +import { prisma } from "~/lib/server/prisma" import { postRaidHubApi } from "~/services/raidhub/common" import { RAIDHUB_INTERNAL_PATHS } from "~/services/raidhub/internalPaths" import { getRaidHubErrorEnvelopeMessage, RaidHubError } from "~/services/raidhub/RaidHubError" @@ -10,17 +10,14 @@ export type PushLinkedRoleMetadataResult = | { ok: true } | { ok: false - code: - | "not_linked" - | "missing_env" - | "refresh_failed" - | "no_profile" - | "enqueue_failed" + code: "not_linked" | "missing_env" | "refresh_failed" | "no_profile" | "enqueue_failed" detail?: string } /** Validates Discord link, loads all Destiny profiles in Prisma, refreshes OAuth, then enqueues sync via api.raidhub.io → Rabbit → Hermes. */ -export async function pushLinkedRoleMetadataForUser(bungieMembershipId: string): Promise { +export async function pushLinkedRoleMetadataForUser( + bungieMembershipId: string +): Promise { const apiUrl = process.env.RAIDHUB_API_URL?.trim() const clientSecret = process.env.RAIDHUB_CLIENT_SECRET?.trim() if (!apiUrl || !clientSecret) { diff --git a/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts b/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts index 62cf4a9a..3997db95 100644 --- a/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts +++ b/src/lib/server/trpc/procedures/user/discordLinkedRolesStatus.ts @@ -1,13 +1,7 @@ import { sanitizeLinkedRoleSyncErrorCode } from "~/lib/server/discord/linkedRoleSyncError" import { protectedProcedure } from "../.." -type SyncHealth = - | "not_linked" - | "needs_scope" - | "needs_reconnect" - | "pending" - | "ok" - | "error" +type SyncHealth = "not_linked" | "needs_scope" | "needs_reconnect" | "pending" | "ok" | "error" function deriveSyncHealth(input: { linked: boolean diff --git a/src/lib/server/trpc/router.ts b/src/lib/server/trpc/router.ts index 389a181d..872c684a 100644 --- a/src/lib/server/trpc/router.ts +++ b/src/lib/server/trpc/router.ts @@ -17,8 +17,8 @@ import { addByAPIKey } from "./procedures/user/account/speedrun-com/addByAPIKey" import { deleteUser } from "./procedures/user/delete" import { discordLinkedRolesStatus } from "./procedures/user/discordLinkedRolesStatus" import { getConnections } from "./procedures/user/getConnections" -import { pushDiscordLinkedRoles } from "./procedures/user/pushDiscordLinkedRoles" import { getPrimaryAuthenticatedProfile } from "./procedures/user/getPrimaryAuthenticatedProfile" +import { pushDiscordLinkedRoles } from "./procedures/user/pushDiscordLinkedRoles" import { updateProfile } from "./procedures/user/updateProfile" import { updateUser } from "./procedures/user/updateUser" diff --git a/src/services/raidhub/common.ts b/src/services/raidhub/common.ts index ef639fac..e32bd505 100644 --- a/src/services/raidhub/common.ts +++ b/src/services/raidhub/common.ts @@ -8,10 +8,9 @@ import { RaidHubError } from "./RaidHubError" import type { paths } from "./openapi" /** openapi-typescript uses `readonly` on `requestBody` / `application/json`. */ -type RequestJsonBody< - T extends keyof paths, - M extends keyof paths[T] -> = paths[T][M] extends { readonly requestBody: infer RB } +type RequestJsonBody = paths[T][M] extends { + readonly requestBody: infer RB +} ? RB extends { readonly content: infer C } ? C extends { readonly "application/json": infer B } ? B diff --git a/src/types/api.ts b/src/types/api.ts index 258ee9a1..277d8a2b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -4,7 +4,8 @@ import { type AppRouter } from "~/lib/server/trpc" export type RouterOutput = inferRouterOutputs /** tRPC `user.discordLinkedRolesStatus` — use from client UI instead of importing server procedure types. */ -export type DiscordLinkedRoleSyncHealth = RouterOutput["user"]["discordLinkedRolesStatus"]["syncHealth"] +export type DiscordLinkedRoleSyncHealth = + RouterOutput["user"]["discordLinkedRolesStatus"]["syncHealth"] export type AppProfile = RouterOutput["profile"]["getUnique"] export type AppUserUpdate = RouterOutput["user"]["update"]