Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0118885
feat(bgs): add the catapult-bgs module with the Battle.net service table
enimaloc Nov 13, 2026
483d06c
test(conventions): check the catapult-bgs sources are written in English
enimaloc Nov 13, 2026
72913f6
feat(bgs): add the BGS frame codec and RPC session
enimaloc Nov 13, 2026
2797536
fix(bgs): notify late close listeners and log swallowed session errors
enimaloc Nov 13, 2026
a5c7987
feat(bgs): merge presence deltas into per-friend game changes
enimaloc Nov 13, 2026
e89cd29
feat(bgs): add the BGS client session, friends list and presence watch
enimaloc Nov 13, 2026
f77c3b6
fix(bgs): flag invitations gone while offline and local closes
enimaloc Nov 13, 2026
f8de5ff
feat(bgs): reconnect BGS sessions with backoff and stop on replaced s…
enimaloc Nov 13, 2026
c61d642
fix(bgs): abort BGS session starts that never complete
enimaloc Nov 13, 2026
d680857
feat(battlenet): add the service account and friend link tables
enimaloc Nov 13, 2026
1345097
feat(battlenet): gate the integration and expose the link state
enimaloc Nov 13, 2026
bd47147
feat(battlenet): mint BGS tickets through a headless login with an ad…
enimaloc Nov 13, 2026
d4f37da
fix(battlenet): stop re-minting tickets after a rejected login
enimaloc Nov 13, 2026
cb5685b
feat(battlenet): drive the Battle.net login page with headless Chromium
enimaloc Nov 13, 2026
b9e9619
chore(docker): run catapult-api on a glibc base so Chromium can start
enimaloc Nov 13, 2026
d2db4bd
fix(battlenet): stop re-minting after repeated login timeouts
enimaloc Nov 13, 2026
36de5ed
fix(docker): keep Playwright from downloading browsers at runtime
enimaloc Nov 13, 2026
31c4c6a
feat(battlenet): keep one BGS session per bot on the active site and …
enimaloc Nov 13, 2026
f7f2d81
fix(battlenet): guard bot accounts against lost updates
enimaloc Nov 13, 2026
1ba9db2
feat(battlenet): manage the bot pool from the admin API
enimaloc Nov 13, 2026
e6fa264
fix(battlenet): handle concurrent bot edits and in-flight logins in t…
enimaloc Nov 13, 2026
d1df994
feat(battlenet): track friend links from BGS friend events
enimaloc Nov 13, 2026
95148c2
fix(battlenet): answer invitations after commit and recover rejected …
enimaloc Nov 13, 2026
cdba7e0
feat(battlenet): link a streamer's Battle.net account through Blizzar…
enimaloc Nov 13, 2026
4486e5a
feat(web): add the Battle.net connection card
enimaloc Nov 13, 2026
4b1b6b4
fix(web): keep Battle.net tags out of moderators' channel streams
enimaloc Nov 13, 2026
fb9983e
feat(web): manage Battle.net bots from the admin Keys tab
enimaloc Nov 13, 2026
6538ae8
fix(web): stop Battle.net polling off the Keys tab and edit bot labels
enimaloc Nov 13, 2026
550093a
fix(docker): copy the catapult-bgs build file into the web image builder
enimaloc Nov 13, 2026
140ad64
test(battlenet): cover the Battle.net routes in the route and access …
enimaloc Nov 13, 2026
8f08de4
fix(battlenet): run at most one ticket mint per bot and drop abandone…
enimaloc Nov 13, 2026
70b20ce
fix(bgs): bound the TLS handshake so a silent server cannot hang a se…
enimaloc Nov 13, 2026
5763fa0
fix(docker): give the catapult user a home directory for Chromium
enimaloc Nov 13, 2026
c3d6ffd
fix(battlenet): forget the Blizzard token once the account is linked
enimaloc Nov 13, 2026
d171bd7
fix(battlenet): only offer the code field while a login waits for it
enimaloc Nov 13, 2026
c8a67dc
fix(battlenet): notify streamers when their bot is deleted
enimaloc Nov 13, 2026
aa1549b
fix(battlenet): resolve the caller through ApiUserResolver in the lin…
enimaloc Nov 13, 2026
c78e520
fix(battlenet): declare the battlenet.integration experiment
enimaloc Nov 13, 2026
bd146c9
fix(battlenet): link through plain OAuth2 so the Catapult user servic…
enimaloc Nov 13, 2026
cd9643c
fix(web): style the Battle.net connect button like the other account …
enimaloc Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions catapult-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,39 @@ RUN apk add --no-cache git
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle ./gradle
COPY catapult-common/build.gradle.kts ./catapult-common/
COPY catapult-bgs/build.gradle.kts ./catapult-bgs/
COPY catapult-api/build.gradle.kts ./catapult-api/
COPY catapult-web/build.gradle.kts ./catapult-web/
RUN ./gradlew :catapult-api:dependencies --no-daemon -q

COPY catapult-common/src ./catapult-common/src
COPY catapult-bgs/src ./catapult-bgs/src
COPY catapult-api/src ./catapult-api/src
COPY CHANGELOG.md ./
COPY .git ./.git
# bootJar alone: `build` would also run the tests and leave a second (-plain) jar for the COPY below
RUN ./gradlew :catapult-api:bootJar --no-daemon

FROM eclipse-temurin:25-jre-alpine
# glibc base: Chromium (Battle.net login, Playwright) does not run on musl
FROM eclipse-temurin:25-jre-noble

WORKDIR /app

RUN apk add --no-cache curl jq
RUN addgroup -S catapult && adduser -S catapult -G catapult
RUN apt-get update && apt-get install -y --no-install-recommends curl jq && rm -rf /var/lib/apt/lists/*
RUN groupadd --system catapult && useradd --system --create-home --home-dir /home/catapult --gid catapult catapult

COPY --from=builder /app/catapult-api/build/libs/*.jar app.jar

RUN chown catapult:catapult app.jar
# Chromium and its OS libraries are installed at build time (never at startup) with Playwright's CLI, run from
# the boot jar's classpath through PropertiesLauncher; the browsers live in a folder the catapult user can read
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN java -Dloader.main=com.microsoft.playwright.CLI -cp app.jar \
org.springframework.boot.loader.launch.PropertiesLauncher install --with-deps chromium \
&& rm -rf /var/lib/apt/lists/* \
&& chown -R catapult:catapult /ms-playwright app.jar

# Only Chromium is baked in: Playwright.create() must not try to download the other browsers at runtime
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

USER catapult

Expand Down
4 changes: 3 additions & 1 deletion catapult-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ val springCloudContextVersion = "5.0.4"

dependencies {
implementation(project(":catapult-common"))
implementation(project(":catapult-bgs"))

implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
Expand Down Expand Up @@ -61,6 +62,7 @@ dependencies {
implementation("io.sentry:sentry-spring-boot-4-starter")
implementation("io.sentry:sentry-logback")
implementation("org.jsoup:jsoup:$jsoupVersion")
implementation("com.microsoft.playwright:playwright:1.64.0")
implementation("org.commonmark:commonmark:$commonmarkVersion")
implementation("org.springframework.cloud:spring-cloud-context:$springCloudContextVersion")

Expand Down Expand Up @@ -133,7 +135,7 @@ val englishConventions = tasks.register<Test>("englishConventions") {
testClassesDirs = testSourceSet.output.classesDirs
classpath = testSourceSet.runtimeClasspath
filter.includeTestsMatching("*EnglishConventionsTest")
listOf("catapult-common", "catapult-web").forEach { module ->
listOf("catapult-common", "catapult-bgs", "catapult-web").forEach { module ->
inputs.dir(rootProject.file("$module/src"))
.withPropertyName("${module}Sources")
.withPathSensitivity(PathSensitivity.RELATIVE)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package fr.enimaloc.catapult.api.admin;

import fr.enimaloc.catapult.api.ApiPaths;
import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin;
import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin.AccountDto;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping(ApiPaths.ADMIN + "/battlenet-accounts")
@RequiredArgsConstructor
@ConditionalOnBooleanProperty("battlenet.enabled")
public class ApiAdminBattleNetAccountsController {

private final BattleNetServiceAccountAdmin accounts;

@GetMapping
public List<AccountDto> list() {
return accounts.list();
}

/** 202: the account is stored and its login runs in the background (poll the list for its auth state). */
@PostMapping
public ResponseEntity<AccountDto> create(@RequestBody Map<String, String> body) {
return ResponseEntity.accepted().body(
accounts.create(body.get("label"), body.get("email"), body.get("password")));
}

/** The 2FA code of a login waiting for one; 409 when none is. */
@PostMapping("/{id}/code")
public ResponseEntity<Void> submitCode(@PathVariable UUID id, @RequestBody Map<String, String> body) {
accounts.submitCode(id, body.get("code"));
return ResponseEntity.accepted().build();
}

@PostMapping("/{id}/reauth")
public ResponseEntity<AccountDto> reauth(@PathVariable UUID id) {
return ResponseEntity.accepted().body(accounts.reauth(id));
}

@PatchMapping("/{id}")
public AccountDto update(@PathVariable UUID id, @RequestBody Map<String, Object> body) {
return accounts.update(id, body);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
accounts.delete(id);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import fr.enimaloc.catapult.common.dto.channel.DtddMappingProposalDto;
import fr.enimaloc.catapult.common.dto.channel.DtddMappingStatusDto;
import fr.enimaloc.catapult.common.dto.channel.GameDto;
import fr.enimaloc.catapult.common.dto.channel.BattleNetData;
import fr.enimaloc.catapult.common.dto.channel.MinecraftData;
import fr.enimaloc.catapult.common.dto.channel.ObsData;
import fr.enimaloc.catapult.common.dto.channel.TwitchatData;
Expand Down Expand Up @@ -51,6 +52,7 @@
import fr.enimaloc.catapult.service.igdb.AdminCclService;
import fr.enimaloc.catapult.api.CustomBindingViews;
import fr.enimaloc.catapult.service.igdb.IgdbService;
import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService;
import fr.enimaloc.catapult.service.minecraft.MinecraftLinkStateService;
import fr.enimaloc.catapult.service.notification.TwitchatWidgetSettingsService;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -92,6 +94,8 @@ public class ChannelDashboardAssembler {
private final OAuthTokenRepository oAuthTokenRepository;
// Only present when minecraft.enabled (the service is @ConditionalOnBooleanProperty).
private final Optional<MinecraftLinkStateService> minecraftLinkStates;
// Only present when battlenet.enabled.
private final Optional<BattleNetLinkStateService> battleNetLinkStates;
private final TwitchatWidgetSettingsService twitchatWidgetSettingsService;
private final RaidRestoreGateService raidRestoreGate;
private final CustomBindingViews customBindingViews;
Expand Down Expand Up @@ -147,6 +151,7 @@ public ChannelPageData page(UserAccount viewer, UserAccount channel, String user
steamData(channel, owner),
xboxEnabled ? new XboxData(hasXboxToken(channel)) : null,
owner ? minecraftData(channel) : null,
owner ? battleNetData(channel) : null,
owner ? obsData(widget) : null,
owner ? TwitchatData.of(widget.getTwitchatBranch()) : null,
custom,
Expand Down Expand Up @@ -249,6 +254,15 @@ private MinecraftData minecraftData(UserAccount channel) {
.orElse(null);
}

/** The Battle.net link as the connect endpoint states it, null without Battle.net or when it's unavailable. */
private BattleNetData battleNetData(UserAccount channel) {
return battleNetLinkStates
.map(service -> service.state(channel))
.filter(state -> !BattleNetLinkStateService.UNAVAILABLE.equals(state.status()))
.map(state -> new BattleNetData(state.status(), state.battleTag(), state.serviceAccountBattleTag()))
.orElse(null);
}

/** The Twitchat relay's OBS settings, without the password itself. */
private static ObsData obsData(TwitchatWidgetSettings settings) {
return new ObsData(settings.isEnabled(), settings.getObsHost(), settings.getObsPort(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package fr.enimaloc.catapult.api.connect;

import fr.enimaloc.catapult.api.ApiPaths;
import fr.enimaloc.catapult.api.ApiUserResolver;
import fr.enimaloc.catapult.api.error.ApiProblem;
import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse;
import fr.enimaloc.catapult.common.error.ErrorCode;
import fr.enimaloc.catapult.domain.account.UserAccount;
import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService;
import fr.enimaloc.catapult.service.battlenet.BattleNetGateService;
import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

/**
* A member's own Battle.net link. Linking itself goes through Blizzard's OAuth flow (see
* {@code CatapultOAuth2UserService}); this only states the link and leaves it. Deliberately not transactional:
* unenrolling commits its own transaction before the bot and the dashboards are told.
*/
@RestController
@RequestMapping(ApiPaths.USER + "/links/battlenet")
@RequiredArgsConstructor
@ConditionalOnBooleanProperty("battlenet.enabled")
public class ApiBattleNetConnectController {

private final BattleNetFriendService friendService;
private final BattleNetLinkStateService linkStates;
private final BattleNetGateService gateService;
private final ApiUserResolver userResolver;

@PreAuthorize("@authz.isSelf(#userId)")
@GetMapping
public BattleNetLinkStateResponse status(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) {
return linkStates.state(userResolver.viewer(jwt));
}

@PreAuthorize("@authz.isSelf(#userId)")
@DeleteMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
public void unenroll(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) {
UserAccount user = userResolver.viewer(jwt);
if (!gateService.isAvailableFor(user)) {
throw new ApiProblem(ErrorCode.FEATURE_DISABLED, "Battle.net is not available for this account");
}
friendService.unenroll(user);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ public ClientRegistrationRepository clientRegistrationRepository() {
}

if (configured(battleNetClientId, battleNetClientSecret)) {
// No "openid": it would switch Spring to the OIDC flow and bypass CatapultOAuth2UserService; Blizzard's userinfo needs no scope.
registrations.add(authorizationCode("battlenet", battleNetClientId, battleNetClientSecret)
.scope("openid")
.authorizationUri("https://oauth.battle.net/authorize")
.tokenUri("https://oauth.battle.net/token")
.userInfoUri("https://oauth.battle.net/userinfo")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package fr.enimaloc.catapult.domain.battlenet;

import fr.enimaloc.catapult.domain.account.UserAccount;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "battlenet_friend_link")
@Getter
@Setter
public class BattleNetFriendLink {

public enum Status {
PENDING, ACCEPTED, REMOVED, INVITE_REJECTED
}

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private UserAccount user;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "service_account_id", nullable = false)
private BattleNetServiceAccount serviceAccount;

/** BGS account id of the streamer (from OAuth userinfo.id). */
@Column(nullable = false)
private long bnetAccountId;

@Column(nullable = false)
private String battleTag;

/** Id of the invitation sent by the bot, when known. */
private Long invitationId;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Status status = Status.PENDING;

@Column(nullable = false)
private Instant requestedAt = Instant.now();

private Instant acceptedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package fr.enimaloc.catapult.domain.battlenet;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "battlenet_service_account")
@Getter
@Setter
@ToString
public class BattleNetServiceAccount {

public enum AuthState {
NEW, CODE_REQUIRED, READY, AUTH_FAILED
}

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@Column(nullable = false)
private String label;

@ToString.Exclude
@Column(nullable = false)
private String email;

/** Battle.net password, encrypted through TokenEncryptionService. */
@ToString.Exclude
@Column(nullable = false, columnDefinition = "text")
private String passwordEnc;

/** Playwright storageState (cookies), encrypted through TokenEncryptionService. */
@ToString.Exclude
@Column(columnDefinition = "text")
private String storageStateEnc;

/** Filled from OnLogonComplete; null before the first login. */
private String battleTag;

/** BGS account id (equals the OAuth userinfo id); null before the first login. */
@Column(unique = true)
private Long bnetAccountId;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthState authState = AuthState.NEW;

@Column(nullable = false)
private int fillOrder;

@Column(nullable = false)
private boolean friendLimitReached;

@Column(nullable = false)
private boolean enabled = true;

@Column(nullable = false)
private Instant createdAt = Instant.now();

@Column(nullable = false)
private Instant updatedAt = Instant.now();

/** Optimistic lock: admin edits and in-flight logins both write the account. */
@Version
private long version;

@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package fr.enimaloc.catapult.experiment;

import fr.enimaloc.catapult.domain.experiment.ExperimentAssignmentRule.RuleType;

/**
* Gate of the Battle.net feature (account linking + presence detection).
* Control 0 % / enabled 100 %: once ACTIVE, everyone is in
* enabled, so activating it in the admin works as the feature's switch.
*/
@ExperimentSpec(
key = "battlenet.integration",
name = "Intégration Battle.net",
description = "Liaison de compte Battle.net et détection de présence via les comptes de service (protocole BGS)",
variants = {
@ExperimentSpec.Variant(key = "control", name = "Sans Battle.net", weight = 0, control = true),
@ExperimentSpec.Variant(key = "enabled", name = "Avec Battle.net", weight = 100)
},
rules = @ExperimentSpec.Rule(type = RuleType.RANDOM, percent = 100)
)
public class BattleNetIntegrationExperiment {}
Loading
Loading