Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b805e87
docs: design achievement fingerprint discovery
Erilla Aug 10, 2026
44670b7
docs: plan achievement fingerprint discovery
Erilla Aug 10, 2026
c9a4c74
feat(blizzard): add ephemeral fingerprint client
Erilla Aug 10, 2026
687db4b
fix(blizzard): enforce fingerprint match floors
Erilla Aug 10, 2026
37d4dd4
docs: keep fingerprint seam dependency-free
Erilla Aug 10, 2026
16abc7a
feat(domain): add cap-aware fingerprint sweep
Erilla Aug 10, 2026
e03bd4e
fix(domain): honor aborts from policy checks
Erilla Aug 10, 2026
c528946
feat(database): reserve fingerprint sweep budget
Erilla Aug 10, 2026
c2da1bc
feat(worker): dispatch fingerprint admissions
Erilla Aug 10, 2026
0e5353d
fix(worker): recover fingerprint admission dispatch
Erilla Aug 10, 2026
0b66dc0
feat(application): merge fingerprint discovery snapshots
Erilla Aug 10, 2026
cc75eff
fix(application): preserve fingerprint sweep invariants
Erilla Aug 10, 2026
7e7e742
feat(worker): configure private Blizzard sweeps
Erilla Aug 10, 2026
6c5a1c4
docs: record fingerprint sweep validation
Erilla Aug 10, 2026
9384b5d
test: include fingerprint sweep tables in migration inventory
Erilla Aug 10, 2026
f23115d
fix(e2e): start worker with local Blizzard fixture
Erilla Aug 10, 2026
c87060d
fix(fingerprint): account physical requests and dispatch admission
Erilla Aug 10, 2026
f75dad9
fix(fingerprint): enforce rolling admissions
Erilla Aug 10, 2026
6c8d1b3
fix(worker): enforce queue exclusivity and alert routing
Erilla Aug 10, 2026
53579cb
fix(worker): harden queue and alert upgrades
Erilla Aug 10, 2026
e42848e
style: apply prettier to fingerprint sweep sources
Erilla Aug 10, 2026
ba9f8a4
fix(database): prune fingerprint request events after their hour
Erilla Aug 10, 2026
837bb76
docs: document the Blizzard sweep and alert environment
Erilla Aug 10, 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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,18 @@ DATABASE_STARTUP_ATTEMPTS=5
DATABASE_STARTUP_RETRY_MS=1000
WORKER_DRAIN_TIMEOUT_MS=30000
WORKER_HEALTH_HOST=127.0.0.1

# Blizzard achievement-fingerprint sweeps (worker service only). The
# credentials and the per-sweep request cap are required; the remaining
# settings fall back to the shared budget, threshold, and cadence decisions.
BLIZZARD_CLIENT_ID=replace-with-the-battle-net-client-id
BLIZZARD_CLIENT_SECRET=replace-with-the-battle-net-client-secret
BLIZZARD_SWEEP_REQUEST_CAP=300
BLIZZARD_HOURLY_REQUEST_BUDGET=28800
FINGERPRINT_MINIMUM_COMMON=200
FINGERPRINT_MINIMUM_IDENTICAL_PERCENT=20
FINGERPRINT_SWEEP_CADENCE_HOURS=168

# Optional internal alert sink for budget and admission pressure. Its path and
# query string are part of the secret, so the whole URL is used as given.
# MAINTAINER_ALERT_WEBHOOK_URL=https://hooks.example.test/services/T000/B000/token
25 changes: 25 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SlashWho

SlashWho publishes World of Warcraft character-relationship information derived from public upstream data.

## Language

**Privacy-hidden ownership**:
The Raider.IO state indicating that a character's ownership link is intentionally not public. It is SlashWho's sole privacy signal for inferred relationships.
_Avoid_: Hidden alt, upstream opt-out

**Fingerprint-derived link**:
A relationship between characters inferred from Blizzard achievement-completion data, rather than declared by Raider.IO.
_Avoid_: Verified link, confirmed alt

**Alt list**:
The public list of characters linked to a root character. It intentionally does not distinguish Raider.IO-declared relationships from fingerprint-derived links.
_Avoid_: Verified-alt list, inferred-alt list

**Partial snapshot**:
An immutable historical result known not to contain every discoverable relationship. It is public as partial while its limitation reason remains internal.
_Avoid_: Failed snapshot, incomplete refresh

**Ephemeral fingerprint**:
Achievement-completion data held only while a single discovery sweep is running. It is discarded before snapshot publication and never becomes a stored signature.
_Avoid_: Fingerprint cache, stored signature
7 changes: 6 additions & 1 deletion apps/web/src/app/api/v1/api-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ const searches: SearchService = {
return snapshotResult;
},
async cleanupExpired() {
return { rateLimits: 0, negativeCache: 0, suppressions: 0 };
return {
rateLimits: 0,
negativeCache: 0,
suppressions: 0,
fingerprintRequests: 0
};
}
};

Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/app/privacy/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// @vitest-environment jsdom

import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { expect, it } from "vitest";

import PrivacyPage from "./page";

it("states the fingerprint privacy boundary without publishing a discovery method", () => {
// Break caught: public documentation could promise privacy while leaving it
// unclear that privacy-hidden ownership is excluded from inferred links.
render(<PrivacyPage />);

expect(
screen.getByText(/privacy-hidden Raider\.IO ownership is excluded/i)
).toBeInTheDocument();
expect(
screen.getByText(/public alt lists do not disclose the discovery method/i)
).toBeInTheDocument();
expect(screen.queryByText(/opt-out/i)).not.toBeInTheDocument();
});
7 changes: 7 additions & 0 deletions apps/web/src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export default function PrivacyPage() {
responses, and internal validation guesses are never stored or shown.
</p>

<h2>Fingerprint-derived links</h2>
<p>
Privacy-hidden Raider.IO ownership is excluded from fingerprint-derived
links. Public alt lists do not disclose the discovery method for any
character relationship.
</p>

<h2>Removal requests</h2>
<p>
Removal requests are manually verified. Submit a request using the{" "}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/server/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ it("migrates and initializes the durable queue before serving searches", async (
async enqueue() {
return "54f14e37-7df7-43db-91d5-21e797d1d145";
},
async enqueueFingerprintAdmission() {
return "54f14e37-7df7-43db-91d5-21e797d1d145";
},
async work() {},
async workFingerprintAdmissions() {},
async scheduleMaintenanceCleanup() {},
async stop() {},
isReady() {
Expand Down
1 change: 1 addition & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"dependencies": {
"@slashwho/application": "workspace:*",
"@slashwho/blizzard": "workspace:*",
"@slashwho/database": "workspace:*",
"@slashwho/domain": "workspace:*",
"@slashwho/raiderio": "workspace:*",
Expand Down
74 changes: 72 additions & 2 deletions apps/worker/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,88 @@ import { expect, it } from "vitest";

import { loadWorkerConfig } from "./config";

const environment = {
DATABASE_URL: "postgresql://slashwho:test@db/slashwho",
BLIZZARD_CLIENT_ID: "worker-client-id",
BLIZZARD_CLIENT_SECRET: "worker-client-secret",
BLIZZARD_SWEEP_REQUEST_CAP: "300"
};

it("rejects missing Blizzard credentials and invalid sweep bounds", () => {
// Break caught: the worker could start a sweep without its private Blizzard
// credentials or reserve an impossible number of upstream requests.
expect(() =>
loadWorkerConfig({ DATABASE_URL: environment.DATABASE_URL })
).toThrow("blizzard_client_id_required");
expect(() =>
loadWorkerConfig({ ...environment, BLIZZARD_SWEEP_REQUEST_CAP: "0" })
).toThrow("invalid_blizzard_sweep_request_cap");
expect(() =>
loadWorkerConfig({
...environment,
FINGERPRINT_MINIMUM_IDENTICAL_PERCENT: "101"
})
).toThrow("invalid_fingerprint_minimum_identical_percent");
expect(() =>
loadWorkerConfig({
...environment,
BLIZZARD_SWEEP_REQUEST_CAP: "301",
BLIZZARD_HOURLY_REQUEST_BUDGET: "300"
})
).toThrow("invalid_blizzard_sweep_request_cap");
});

it("loads private Blizzard sweep defaults only for the worker", () => {
// Break caught: an omitted operational limit could silently become unbounded
// or make the planned seven-day sweep cadence depend on another process.
expect(loadWorkerConfig(environment)).toMatchObject({
blizzardClientId: environment.BLIZZARD_CLIENT_ID,
blizzardClientSecret: environment.BLIZZARD_CLIENT_SECRET,
blizzardSweepRequestCap: 300,
blizzardHourlyRequestBudget: 28_800,
fingerprintMinimumCommon: 200,
fingerprintMinimumIdenticalPercent: 20,
fingerprintSweepCadenceHours: 168
});
});

it("accepts a local Blizzard endpoint only when explicitly configured", () => {
// Break caught: e2e could not direct its fake credentials and sweep requests
// to its deterministic local fixture.
expect(
loadWorkerConfig({
...environment,
BLIZZARD_BASE_URL: "http://127.0.0.1:43101"
}).blizzardBaseUrl
).toBe("http://127.0.0.1:43101");
});

it("preserves a maintainer webhook path and query string", () => {
// Break caught: URL validation could reduce a provider webhook to its origin,
// posting alerts to the provider homepage instead of the secret endpoint.
const webhookUrl =
"https://hooks.example.test/services/T000/B000/token?wait=true";

expect(
loadWorkerConfig({
...environment,
MAINTAINER_ALERT_WEBHOOK_URL: webhookUrl
}).maintainerAlertWebhookUrl
).toBe(webhookUrl);
});

it("accepts only explicit loopback or container health hosts", () => {
// Break caught: a deploy could silently bind to an unusable or arbitrary
// interface instead of the intended local/container health boundary.
expect(
loadWorkerConfig({
DATABASE_URL: "postgresql://slashwho:test@db/slashwho",
...environment,
WORKER_HEALTH_HOST: "0.0.0.0"
}).healthHost
).toBe("0.0.0.0");
expect(() =>
loadWorkerConfig({
DATABASE_URL: "postgresql://slashwho:test@db/slashwho",
...environment,
WORKER_HEALTH_HOST: "public.example"
})
).toThrow("invalid_worker_health_host");
Expand Down
94 changes: 94 additions & 0 deletions apps/worker/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export type WorkerConfig = {
negativeCacheTtlMs: number;
raiderIoBaseUrl: string;
raiderIoTimeoutMs: number;
blizzardClientId: string;
blizzardClientSecret: string;
blizzardBaseUrl?: string;
blizzardSweepRequestCap: number;
blizzardHourlyRequestBudget: number;
fingerprintMinimumCommon: number;
fingerprintMinimumIdenticalPercent: number;
fingerprintSweepCadenceHours: number;
maintainerAlertWebhookUrl?: string;
};

function positiveInteger(
Expand All @@ -21,6 +30,41 @@ function positiveInteger(
return parsed;
}

function integerInRange(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
code: string
): number {
const parsed = value === undefined ? fallback : Number(value);
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new Error(code);
}
return parsed;
}

function requiredString(value: string | undefined, code: string): string {
if (!value?.trim()) throw new Error(code);
return value;
}

function optionalHttpUrl(
value: string | undefined,
code: string
): string | undefined {
if (value === undefined) return undefined;
try {
const normalized = value.trim();
const url = new URL(normalized);
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error();
return normalized;
} catch {
throw new Error(code);
}
}

export function loadWorkerConfig(
environment: NodeJS.ProcessEnv = process.env
): WorkerConfig {
Expand All @@ -29,6 +73,27 @@ export function loadWorkerConfig(
if (healthHost !== "127.0.0.1" && healthHost !== "0.0.0.0") {
throw new Error("invalid_worker_health_host");
}
const blizzardClientId = requiredString(
environment.BLIZZARD_CLIENT_ID,
"blizzard_client_id_required"
);
const blizzardClientSecret = requiredString(
environment.BLIZZARD_CLIENT_SECRET,
"blizzard_client_secret_required"
);
const blizzardSweepRequestCap = positiveInteger(
environment.BLIZZARD_SWEEP_REQUEST_CAP,
0,
"invalid_blizzard_sweep_request_cap"
);
const blizzardHourlyRequestBudget = positiveInteger(
environment.BLIZZARD_HOURLY_REQUEST_BUDGET,
28_800,
"invalid_blizzard_hourly_request_budget"
);
if (blizzardSweepRequestCap > blizzardHourlyRequestBudget) {
throw new Error("invalid_blizzard_sweep_request_cap");
}

return {
databaseUrl: environment.DATABASE_URL,
Expand Down Expand Up @@ -65,6 +130,35 @@ export function loadWorkerConfig(
environment.RAIDER_IO_TIMEOUT_MS,
10_000,
"invalid_raiderio_timeout"
),
blizzardClientId,
blizzardClientSecret,
blizzardBaseUrl: optionalHttpUrl(
environment.BLIZZARD_BASE_URL,
"invalid_blizzard_base_url"
),
blizzardSweepRequestCap,
blizzardHourlyRequestBudget,
fingerprintMinimumCommon: positiveInteger(
environment.FINGERPRINT_MINIMUM_COMMON,
200,
"invalid_fingerprint_minimum_common"
),
fingerprintMinimumIdenticalPercent: integerInRange(
environment.FINGERPRINT_MINIMUM_IDENTICAL_PERCENT,
20,
1,
100,
"invalid_fingerprint_minimum_identical_percent"
),
fingerprintSweepCadenceHours: positiveInteger(
environment.FINGERPRINT_SWEEP_CADENCE_HOURS,
168,
"invalid_fingerprint_sweep_cadence_hours"
),
maintainerAlertWebhookUrl: optionalHttpUrl(
environment.MAINTAINER_ALERT_WEBHOOK_URL,
"invalid_maintainer_alert_webhook_url"
)
};
}
38 changes: 38 additions & 0 deletions apps/worker/src/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,42 @@ describe("worker logger", () => {
expect(captured).toContain("[Circular]");
expect(captured).not.toContain(marker);
});

it("redacts every ephemeral fingerprint and credential marker", async () => {
// Break caught: diagnostic objects could serialize achievement material,
// access tokens, or comparison scores outside the handler allowlist.
const marker = "UNIQUE_FINGERPRINT_MARKER_414f8b";
const output = new PassThrough();
let captured = "";
output.on("data", (chunk) => {
captured += chunk.toString();
});
const logger = createWorkerLogger(output);

logger.info(
{
achievementId: marker,
achievementIds: marker,
achievementTimestamp: marker,
completionTimestamp: marker,
accessToken: marker,
refreshToken: marker,
fingerprint: marker,
fingerprintScore: marker,
matchScore: marker,
identicalPercent: marker,
nested: {
achievements: marker,
timestamps: marker,
token: marker,
score: marker
}
},
"fingerprint_event"
);
await new Promise((resolve) => setImmediate(resolve));

expect(captured).toContain("fingerprint_event");
expect(captured).not.toContain(marker);
});
});
16 changes: 15 additions & 1 deletion apps/worker/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,21 @@ const sensitiveKeys = new Set([
"validationname",
"rawurl",
"rawpayload",
"rawupstreampayload"
"rawupstreampayload",
"achievementid",
"achievementids",
"achievements",
"achievementtimestamp",
"completiontimestamp",
"timestamps",
"accesstoken",
"refreshtoken",
"token",
"fingerprint",
"fingerprintscore",
"matchscore",
"identicalpercent",
"score"
]);

function sanitize(value: unknown, visited = new WeakSet<object>()): unknown {
Expand Down
Loading
Loading