From 44babe88b54309a3c78973d8fab1be339015a6bc Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 20:29:30 -0400 Subject: [PATCH 01/28] docs: plan 2 sync engine implementation plan --- .../plans/2026-08-02-authgd-2-sync-engine.md | 4793 +++++++++++++++++ 1 file changed, 4793 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md diff --git a/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md b/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md new file mode 100644 index 00000000..2f85d31c --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md @@ -0,0 +1,4793 @@ +# authGD Plan 2/3: Sync Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The complete background sync engine: a pg-boss worker (second container, same image) running the outbox dispatcher plus the five spec jobs — membership verification, contact push, Wanderer ACL sync, Discord role sync, token health — and the carry-over purge jobs, with `sync_run` recording and ops-webhook alerting. + +**Architecture:** All job logic lives in `src/jobs/*` as plain async functions taking injected dependencies (`Db`, `Config`, and per-integration clients), so every job is testable without pg-boss. Pure diff/decision logic (contacts, ACL, roles, tier, affiliation bisection) lives in `src/core/*` with table-driven unit tests. Integration clients (`src/lib/esi/client.ts`, `src/lib/wanderer/client.ts`, `src/lib/discord/rest.ts`) own HTTP, validation (fail-closed zod), and transient/permanent classification. The worker entry (`src/worker/index.ts`) wires pg-boss v10 queues, schedules, the outbox dispatcher, and a dead-letter queue that posts the ops webhook after final retry failure. + +**Tech Stack:** TypeScript (strict), pg-boss ^10.3 (already a dependency), Drizzle ORM + node-postgres, zod, vitest, msw (HTTP-level client tests). + +**Spec:** `docs/superpowers/specs/2026-08-02-authgd-design.md` — authoritative for all behavior ("Sync jobs", "Error handling", tier model). +**Carry-over:** `docs/superpowers/plans/2026-08-02-authgd-plan2-3-carryover.md` — binding constraints from Plan 1's reviews. + +## Global Constraints + +- Everything in Plan 1's Global Constraints still applies (strict TS, no `any`, DbTx-only identity mutations, audit rows for state changes, conventional commits after every green cycle, `npm test` needs the dev-compose Postgres on port 5433). +- **Every job execution records exactly one `sync_run` row** via the `runJob` wrapper (Task 2). Result policy: full success → `ok`; transient failures affecting part of the run → `partial` **and throw `JobRetryError`** so pg-boss retries (jobs are idempotent, re-running is always safe); permanent/config failures → `failed` **without throwing** (no retry loop). Unexpected exceptions → `failed` + rethrow (pg-boss retries). +- **Error classification everywhere:** reuse `classifyOAuthError` / `classifyEsiError` from `src/core/errors.ts`. Affiliation bisection happens ONLY on deterministic HTTP 400. `token_status: invalid` only on permanent OAuth errors or a malformed stored token blob. Transient failures (420/429/5xx/network) never change persisted state. +- **Never remove on unknown state:** a failed read (contacts pages, ACL) aborts that reconciliation scope before any destructive write. Membership transitions require a confirmed affiliation read of the main in the same run. +- **Tier transitions commit with their downstream job triggers in one transaction:** account row locked FOR UPDATE, re-checked, updated, audit-logged, and the `outbox` row written — all in one `db.transaction`. +- **pg-boss v10 API facts** (these shape the worker code; do not "simplify" them away): + - Queues must be created explicitly with `boss.createQueue(name, options)` before send/work. + - `boss.work` handlers receive an **array** of jobs. + - There is no `onComplete`; "ops webhook after final retry failure" is implemented with a **dead-letter queue** (`ops-dead-letter`) that all job queues point at, plus an immediate webhook post for permanent-config failures. + - Retry policy on every queue: `retryLimit: 5, retryDelay: 60, retryBackoff: true` (~5 tries over ~30 min). + - Every `send`/`schedule` payload includes a `jobType` field so the dead-letter handler can name the failed job. + - Duplicate on-demand triggers are coalesced with `singletonKey` on `send`. +- **No new advisory locks.** Character locks own class 1 (`pg_advisory_xact_lock(1, hashint8(id))` in `src/services/accounts.ts`); the dispatcher needs none because `takeUndispatched` uses FOR UPDATE SKIP LOCKED — claim and `markDispatched` happen in the SAME transaction (contract documented in `src/services/outbox.ts`). +- **Outbox fan-out semantics:** a `{kind:"account"}` row fans out to account-scoped membership + Discord-role jobs but **global** contacts/Wanderer jobs — adding a character to one account changes the desired set pushed to every other member. Global jobs coalesce via fixed singleton keys; ~20 accounts makes this cheap. The hourly schedules remain the backstop. +- **Wanderer API contract is assumed, not verified** (repo records only base URL/API key/ACL id). The assumed contract (`GET|POST /api/acls/{aclId}/members`, `DELETE /api/acls/{aclId}/members/{characterEveId}`, bearer auth, `eve_character_id` as digit-string) is isolated in `src/lib/wanderer/client.ts` and pinned by msw tests. **Verifying paths/shapes against the live instance is a deploy-time step (Plan 3).** +- All external clients accept an injectable `fetchImpl` (Plan 1 convention). Jobs declare client dependencies as `Pick` so tests inject fakes; HTTP behavior itself is tested at the client layer with msw. +- Tests: table-driven unit tests for pure logic; integration tests against `TEST_DATABASE_URL` (dev compose Postgres) via `tests/helpers/db.ts`; msw for HTTP in client tests. `vitest` runs files serially (`fileParallelism: false`) so DB tests don't interfere. +- The worker runs with `npm run worker` (tsx). Dockerfile/second-container start command is Plan 3 scope. + +--- + +### Task 1: Discord OAuth fail-closed validation (carry-over) + +`src/lib/discord/oauth.ts` currently blind-casts token/user JSON; `user.id` feeds the unique `discord_user_id` identity column. Tighten to fail-closed zod validation, mirroring `src/lib/esi/sso.ts`. Also create the shared test-config helper used by all later tasks. + +**Files:** +- Modify: `src/lib/discord/oauth.ts` +- Create: `tests/helpers/config.ts` +- Test: `tests/discord-oauth.test.ts` + +**Interfaces:** +- Consumes: `Config` from `src/config.ts`. +- Produces: `exchangeDiscordCode` / `fetchDiscordUser` keep their existing signatures but throw `DiscordOAuthError` (message contains "malformed") on any response that fails validation. `class DiscordOAuthError extends Error { status?: number }`. +- Produces: `testConfig(overrides?: Partial): Config` in `tests/helpers/config.ts` — a fully valid Config for tests (ops webhook `https://discord.example/webhook`, wanderer base `https://wanderer.example`, standings label `flygd`, value 5, alliance 99000001, scopes = both contact scopes). Later test tasks consume this. + +- [ ] **Step 1: Write the test helper and failing test** + +`tests/helpers/config.ts`: + +```ts +import { loadConfig, type Config } from "@/config"; + +export function testConfig(overrides: Partial = {}): Config { + return loadConfig({ + DATABASE_URL: "postgres://x/y", + TOKEN_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString("base64"), + APP_BASE_URL: "https://auth.example", + ALLIANCE_ID: "99000001", + BOOTSTRAP_ADMIN_CHARACTER_IDS: "", + EVE_SSO_CLIENT_ID: "client-id", + EVE_SSO_CLIENT_SECRET: "client-secret", + EVE_SSO_SCOPES: + "esi-characters.read_contacts.v1 esi-characters.write_contacts.v1", + EVE_SCOPE_SET_VERSION: "1", + DISCORD_CLIENT_ID: "d-cid", + DISCORD_CLIENT_SECRET: "d-sec", + DISCORD_BOT_TOKEN: "bot-token", + DISCORD_GUILD_ID: "9000", + DISCORD_ROLE_ID_FLYGD: "10", + DISCORD_ROLE_ID_BLUE: "11", + DISCORD_ROLE_ID_GREEN: "12", + DISCORD_OPS_WEBHOOK_URL: "https://discord.example/webhook", + WANDERER_BASE_URL: "https://wanderer.example", + WANDERER_API_KEY: "wkey", + WANDERER_MAP_SLUG: "map", + WANDERER_ACL_ID: "acl-1", + STANDINGS_LABEL: "flygd", + STANDINGS_VALUE: "5", + ...overrides, + } as NodeJS.ProcessEnv); +} +``` + +`tests/discord-oauth.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { exchangeDiscordCode, fetchDiscordUser } from "@/lib/discord/oauth"; +import { testConfig } from "./helpers/config"; + +const cfg = testConfig(); + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("exchangeDiscordCode", () => { + it("returns the access token", async () => { + const fetchImpl = (async () => jsonResponse({ access_token: "tok" })) as typeof fetch; + expect(await exchangeDiscordCode(cfg, "c", "v", fetchImpl)).toEqual({ + accessToken: "tok", + }); + }); + + it("fails closed on a malformed token response", async () => { + const fetchImpl = (async () => jsonResponse({ nope: true })) as typeof fetch; + await expect(exchangeDiscordCode(cfg, "c", "v", fetchImpl)).rejects.toThrow(/malformed/); + }); + + it("fails closed on an empty access_token", async () => { + const fetchImpl = (async () => jsonResponse({ access_token: "" })) as typeof fetch; + await expect(exchangeDiscordCode(cfg, "c", "v", fetchImpl)).rejects.toThrow(/malformed/); + }); +}); + +describe("fetchDiscordUser", () => { + it("returns id and username", async () => { + const fetchImpl = (async () => + jsonResponse({ id: "123456789", username: "pilot" })) as typeof fetch; + expect(await fetchDiscordUser("at", fetchImpl)).toEqual({ + id: "123456789", + username: "pilot", + }); + }); + + it("rejects a non-snowflake id (feeds a unique identity column)", async () => { + const fetchImpl = (async () => + jsonResponse({ id: "abc", username: "pilot" })) as typeof fetch; + await expect(fetchDiscordUser("at", fetchImpl)).rejects.toThrow(/malformed/); + }); + + it("rejects a non-JSON body", async () => { + const fetchImpl = (async () => + new Response("oops", { status: 200 })) as typeof fetch; + await expect(fetchDiscordUser("at", fetchImpl)).rejects.toThrow(/malformed/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/discord-oauth.test.ts` +Expected: FAIL (`/malformed/` cases — current code blind-casts). + +- [ ] **Step 3: Implement fail-closed validation** + +Replace the JSON handling in `src/lib/discord/oauth.ts` (keep `buildDiscordAuthorizeUrl` unchanged): + +```ts +import { z } from "zod"; +import type { Config } from "@/config"; + +export class DiscordOAuthError extends Error { + status?: number; + constructor(message: string, status?: number) { + super(message); + this.status = status; + } +} + +const tokenResponseSchema = z.object({ access_token: z.string().min(1) }); +// Snowflake ids are decimal digit strings; this value feeds the unique +// discord_user_id identity column, so anything else is rejected outright. +const userResponseSchema = z.object({ + id: z.string().regex(/^\d+$/), + username: z.string().min(1), +}); + +export function buildDiscordAuthorizeUrl( + cfg: Config, + state: string, + codeChallenge: string, +): string { + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", cfg.discord.clientId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", `${cfg.appBaseUrl}/auth/discord/callback`); + url.searchParams.set("scope", "identify"); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export async function exchangeDiscordCode( + cfg: Config, + code: string, + codeVerifier: string, + fetchImpl: typeof fetch = fetch, +): Promise<{ accessToken: string }> { + const res = await fetchImpl("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: cfg.discord.clientId, + client_secret: cfg.discord.clientSecret, + grant_type: "authorization_code", + code, + code_verifier: codeVerifier, + redirect_uri: `${cfg.appBaseUrl}/auth/discord/callback`, + }).toString(), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + throw new DiscordOAuthError(`discord token exchange failed (${res.status})`, res.status); + } + const parsed = tokenResponseSchema.safeParse(await res.json().catch(() => undefined)); + if (!parsed.success) throw new DiscordOAuthError("discord token response malformed"); + return { accessToken: parsed.data.access_token }; +} + +export async function fetchDiscordUser( + accessToken: string, + fetchImpl: typeof fetch = fetch, +): Promise<{ id: string; username: string }> { + const res = await fetchImpl("https://discord.com/api/users/@me", { + headers: { authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + throw new DiscordOAuthError(`discord user fetch failed (${res.status})`, res.status); + } + const parsed = userResponseSchema.safeParse(await res.json().catch(() => undefined)); + if (!parsed.success) throw new DiscordOAuthError("discord user response malformed"); + return { id: parsed.data.id, username: parsed.data.username }; +} +``` + +- [ ] **Step 4: Run tests to verify pass (including existing suites)** + +Run: `npm test -- tests/discord-oauth.test.ts tests/discord-link.test.ts tests/auth-routes.test.ts` +Expected: PASS (existing suites mock well-formed responses, so they stay green). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/discord/oauth.ts tests/discord-oauth.test.ts tests/helpers/config.ts +git commit -m "feat: fail-closed validation for Discord OAuth responses" +``` + +--- + +### Task 2: sync_run service and ops webhook + +**Files:** +- Create: `src/services/sync-run.ts`, `src/lib/ops-webhook.ts` +- Test: `tests/sync-run.test.ts`, `tests/ops-webhook.test.ts` + +**Interfaces:** +- Consumes: `Db`, `Dbx`, `syncRun` table, `Config`. +- Produces: + - `type JobResult = { status: "ok" | "partial" | "failed"; errorSummary?: string; counts?: Record; retry?: boolean }` + - `startSyncRun(dbx: Dbx, jobType: string): Promise`; `finishSyncRun(dbx: Dbx, id: number, result: Omit): Promise` + - `runJob(db: Db, jobType: string, fn: () => Promise): Promise` — records start/finish around `fn`; on thrown error records `failed` and rethrows; when `result.retry` is true, records the result then throws `JobRetryError` so pg-boss retries. **Every job in Tasks 6–12 wraps its body in this.** + - `class JobRetryError extends Error` + - `postOpsWebhook(cfg: Config, content: string, fetchImpl?: typeof fetch): Promise` — POSTs `{ content }` to `cfg.discord.opsWebhookUrl`; no-op when unset; **never throws** (alerting must not break jobs); content truncated to 1900 chars. + +- [ ] **Step 1: Write failing tests** + +`tests/sync-run.test.ts`: + +```ts +import { desc } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { syncRun } from "@/db/schema"; +import { JobRetryError, runJob } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); + +async function latestRun() { + const rows = await ctx.db + .select() + .from(syncRun) + .orderBy(desc(syncRun.id)) + .limit(1); + return rows[0]; +} + +describe("runJob", () => { + it("records an ok run with counts", async () => { + await runJob(ctx.db, "membership", async () => ({ + status: "ok", + counts: { resolved: 3 }, + })); + const run = await latestRun(); + expect(run.jobType).toBe("membership"); + expect(run.status).toBe("ok"); + expect(run.finishedAt).not.toBeNull(); + expect(run.counts).toEqual({ resolved: 3 }); + }); + + it("records failed and rethrows on unexpected errors", async () => { + await expect( + runJob(ctx.db, "contacts", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + const run = await latestRun(); + expect(run.status).toBe("failed"); + expect(run.errorSummary).toContain("boom"); + }); + + it("records the result then throws JobRetryError when retry is requested", async () => { + await expect( + runJob(ctx.db, "wanderer", async () => ({ + status: "partial", + errorSummary: "2 transient failures", + retry: true, + })), + ).rejects.toBeInstanceOf(JobRetryError); + const run = await latestRun(); + expect(run.status).toBe("partial"); + expect(run.errorSummary).toBe("2 transient failures"); + }); + + it("records failed WITHOUT throwing for permanent-config results", async () => { + const result = await runJob(ctx.db, "discord-roles", async () => ({ + status: "failed", + errorSummary: "managed role ids are not distinct", + })); + expect(result.status).toBe("failed"); + const run = await latestRun(); + expect(run.status).toBe("failed"); + }); +}); +``` + +`tests/ops-webhook.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { postOpsWebhook } from "@/lib/ops-webhook"; +import { testConfig } from "./helpers/config"; + +describe("postOpsWebhook", () => { + it("posts content to the configured webhook", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 204 })); + await postOpsWebhook(testConfig(), "job failed", fetchImpl as unknown as typeof fetch); + expect(fetchImpl).toHaveBeenCalledOnce(); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://discord.example/webhook"); + expect(JSON.parse(init.body as string)).toEqual({ content: "job failed" }); + }); + + it("is a no-op when no webhook is configured", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 204 })); + const cfg = testConfig({ DISCORD_OPS_WEBHOOK_URL: "" }); + await postOpsWebhook(cfg, "x", fetchImpl as unknown as typeof fetch); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("never throws, even when the post fails", async () => { + const fetchImpl = (async () => { + throw new Error("network down"); + }) as typeof fetch; + await expect(postOpsWebhook(testConfig(), "x", fetchImpl)).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- tests/sync-run.test.ts tests/ops-webhook.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/services/sync-run.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Db, Dbx } from "@/db"; +import { syncRun } from "@/db/schema"; + +export type JobResult = { + status: "ok" | "partial" | "failed"; + errorSummary?: string; + counts?: Record; + /** When true, runJob throws JobRetryError after recording so pg-boss retries. */ + retry?: boolean; +}; + +export class JobRetryError extends Error {} + +export async function startSyncRun(dbx: Dbx, jobType: string): Promise { + const [row] = await dbx.insert(syncRun).values({ jobType }).returning(); + return row.id; +} + +export async function finishSyncRun( + dbx: Dbx, + id: number, + result: Omit, +): Promise { + await dbx + .update(syncRun) + .set({ + finishedAt: new Date(), + status: result.status, + errorSummary: result.errorSummary ?? null, + counts: result.counts ?? null, + }) + .where(eq(syncRun.id, id)); +} + +/** + * Uniform job wrapper: one sync_run row per execution. Transient trouble is + * reported via result.retry (recorded, then thrown as JobRetryError so pg-boss + * retries the idempotent job); permanent/config failures return status + * "failed" WITHOUT retry so they don't retry-loop. + */ +export async function runJob( + db: Db, + jobType: string, + fn: () => Promise, +): Promise { + const id = await startSyncRun(db, jobType); + let result: JobResult; + try { + result = await fn(); + } catch (err) { + await finishSyncRun(db, id, { + status: "failed", + errorSummary: err instanceof Error ? err.message : String(err), + }); + throw err; + } + await finishSyncRun(db, id, result); + if (result.retry) { + throw new JobRetryError(`${jobType}: ${result.errorSummary ?? "transient failures"}`); + } + return result; +} +``` + +`src/lib/ops-webhook.ts`: + +```ts +import type { Config } from "@/config"; + +/** Posts to the optional Discord ops webhook. Never throws — alerting must not break jobs. */ +export async function postOpsWebhook( + cfg: Config, + content: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = cfg.discord.opsWebhookUrl; + if (!url) return; + try { + const res = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: content.slice(0, 1900) }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) console.error(`ops webhook post failed (${res.status})`); + } catch (err) { + console.error("ops webhook post failed", err); + } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- tests/sync-run.test.ts tests/ops-webhook.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/services/sync-run.ts src/lib/ops-webhook.ts tests/sync-run.test.ts tests/ops-webhook.test.ts +git commit -m "feat: sync_run job wrapper and ops webhook" +``` + +--- + +### Task 3: ESI client with error-limit throttling + +**Files:** +- Create: `src/lib/esi/client.ts`, `src/core/chunk.ts` +- Test: `tests/esi-client.test.ts` + +**Interfaces:** +- Consumes: `classifyEsiError` from `src/core/errors.ts`. +- Produces: + - `chunk(items: T[], size: number): T[][]` in `src/core/chunk.ts`. + - `class EsiError extends Error { status: number; kind: "transient" | "permanent" | "needs_reauth" }` + - `createEsiClient(opts?: { fetchImpl?: typeof fetch; now?: () => number; sleep?: (ms: number) => Promise; errorBudgetFloor?: number }): EsiClient` and `type EsiClient = ReturnType` with methods: + - `postAffiliation(ids: number[]): Promise>` (public endpoint, ≤500 ids per call — throws if given more; chunking is the caller's job) + - `getContactLabels(characterId: number, accessToken: string): Promise>` + - `getAllContacts(characterId: number, accessToken: string): Promise` — reads ALL pages (X-Pages header); ANY page failure rejects the whole call (partial reads are unsafe for destructive diffs) + - `addContacts(characterId, accessToken, contactIds: number[], standing: number, labelIds: number[]): Promise` (chunks of 100) + - `editContacts(...same signature as addContacts): Promise` (PUT, chunks of 100) + - `deleteContacts(characterId, accessToken, contactIds: number[]): Promise` (chunks of 20, query param) + - `type EsiContact = { contactId: number; contactType: string; standing: number; labelIds: number[] }` + - `type Affiliation = { characterId: number; corporationId: number; allianceId: number | null }` +- ESI etiquette: the client tracks `X-ESI-Error-Limit-Remain`/`-Reset` from every response; before a request, if the remaining budget is at or below the floor (default 5) and the reset is in the future, it sleeps until reset. + +- [ ] **Step 1: Write failing tests** + +`tests/esi-client.test.ts` (msw at the HTTP level): + +```ts +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createEsiClient, EsiError } from "@/lib/esi/client"; +import { chunk } from "@/core/chunk"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const BASE = "https://esi.evetech.net/latest"; + +describe("chunk", () => { + it("splits into fixed-size chunks", () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + expect(chunk([], 3)).toEqual([]); + }); +}); + +describe("postAffiliation", () => { + it("maps fields and defaults missing alliance to null", async () => { + server.use( + http.post(`${BASE}/characters/affiliation/`, async ({ request }) => { + expect(await request.json()).toEqual([1, 2]); + return HttpResponse.json([ + { character_id: 1, corporation_id: 100, alliance_id: 99000001 }, + { character_id: 2, corporation_id: 200 }, + ]); + }), + ); + const esi = createEsiClient(); + expect(await esi.postAffiliation([1, 2])).toEqual([ + { characterId: 1, corporationId: 100, allianceId: 99000001 }, + { characterId: 2, corporationId: 200, allianceId: null }, + ]); + }); + + it("rejects more than 500 ids", async () => { + const esi = createEsiClient(); + await expect( + esi.postAffiliation(Array.from({ length: 501 }, (_, i) => i + 1)), + ).rejects.toThrow(/500/); + }); + + it("throws a classified EsiError on failure", async () => { + server.use( + http.post(`${BASE}/characters/affiliation/`, () => + HttpResponse.json({ error: "rate limited" }, { status: 420 }), + ), + ); + const esi = createEsiClient(); + const err = await esi.postAffiliation([1]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(EsiError); + expect((err as EsiError).status).toBe(420); + expect((err as EsiError).kind).toBe("transient"); + }); + + it("fails closed on a malformed body", async () => { + server.use( + http.post(`${BASE}/characters/affiliation/`, () => + HttpResponse.json([{ character_id: "not-a-number" }]), + ), + ); + const esi = createEsiClient(); + await expect(esi.postAffiliation([1])).rejects.toThrow(); + }); +}); + +describe("error-limit throttling", () => { + it("pauses until reset when the error budget is low", async () => { + let calls = 0; + server.use( + http.post(`${BASE}/characters/affiliation/`, () => { + calls++; + return HttpResponse.json([], { + headers: { + "X-ESI-Error-Limit-Remain": "3", + "X-ESI-Error-Limit-Reset": "42", + }, + }); + }), + ); + const sleeps: number[] = []; + let nowMs = 1_000_000; + const esi = createEsiClient({ + now: () => nowMs, + sleep: async (ms) => { + sleeps.push(ms); + nowMs += ms; + }, + }); + await esi.postAffiliation([1]); // response says remain=3 (≤ floor of 5) + await esi.postAffiliation([2]); // must pause until reset first + expect(calls).toBe(2); + expect(sleeps).toEqual([42_000]); + }); +}); + +describe("contacts", () => { + it("reads all pages before returning", async () => { + const pages: Record = { + "1": [{ contact_id: 11, contact_type: "character", standing: 5, label_ids: [7] }], + "2": [{ contact_id: 12, contact_type: "character", standing: 0 }], + }; + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, ({ request }) => { + const page = new URL(request.url).searchParams.get("page") ?? "1"; + return HttpResponse.json(pages[page], { headers: { "X-Pages": "2" } }); + }), + ); + const esi = createEsiClient(); + expect(await esi.getAllContacts(90000001, "at")).toEqual([ + { contactId: 11, contactType: "character", standing: 5, labelIds: [7] }, + { contactId: 12, contactType: "character", standing: 0, labelIds: [] }, + ]); + }); + + it("rejects the whole read when any page fails", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, ({ request }) => { + const page = new URL(request.url).searchParams.get("page") ?? "1"; + if (page === "2") return HttpResponse.json({ error: "boom" }, { status: 500 }); + return HttpResponse.json( + [{ contact_id: 11, contact_type: "character", standing: 5 }], + { headers: { "X-Pages": "2" } }, + ); + }), + ); + const esi = createEsiClient(); + await expect(esi.getAllContacts(90000001, "at")).rejects.toBeInstanceOf(EsiError); + }); + + it("sends the bearer token and label/standing params on writes, chunked at 100", async () => { + const bodies: number[][] = []; + server.use( + http.post(`${BASE}/characters/90000001/contacts/`, async ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bearer at"); + const url = new URL(request.url); + expect(url.searchParams.get("standing")).toBe("5"); + expect(url.searchParams.getAll("label_ids")).toEqual(["7"]); + bodies.push((await request.json()) as number[]); + return HttpResponse.json([], { status: 201 }); + }), + ); + const esi = createEsiClient(); + const ids = Array.from({ length: 150 }, (_, i) => i + 1); + await esi.addContacts(90000001, "at", ids, 5, [7]); + expect(bodies.map((b) => b.length)).toEqual([100, 50]); + }); + + it("chunks deletes at 20 via query params", async () => { + const deletes: string[] = []; + server.use( + http.delete(`${BASE}/characters/90000001/contacts/`, ({ request }) => { + deletes.push(new URL(request.url).searchParams.get("contact_ids") ?? ""); + return HttpResponse.json([]); + }), + ); + const esi = createEsiClient(); + await esi.deleteContacts(90000001, "at", Array.from({ length: 45 }, (_, i) => i + 1)); + expect(deletes).toHaveLength(3); + expect(deletes[0].split(",")).toHaveLength(20); + expect(deletes[2].split(",")).toHaveLength(5); + }); + + it("parses contact labels", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/labels/`, () => + HttpResponse.json([{ label_id: 7, label_name: "flygd" }]), + ), + ); + const esi = createEsiClient(); + expect(await esi.getContactLabels(90000001, "at")).toEqual([ + { labelId: 7, labelName: "flygd" }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- tests/esi-client.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/core/chunk.ts`: + +```ts +export function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} +``` + +`src/lib/esi/client.ts`: + +```ts +import { z } from "zod"; +import { chunk } from "@/core/chunk"; +import { classifyEsiError, type EsiErrorClass } from "@/core/errors"; + +const ESI_BASE = "https://esi.evetech.net/latest"; +const WRITE_CHUNK = 100; // ESI POST/PUT contacts body limit +const DELETE_CHUNK = 20; // ESI DELETE contacts query limit +const AFFILIATION_MAX = 500; + +export class EsiError extends Error { + status: number; + kind: EsiErrorClass; + constructor(message: string, status: number, kind: EsiErrorClass) { + super(message); + this.status = status; + this.kind = kind; + } +} + +const affiliationSchema = z.array( + z.object({ + character_id: z.number().int(), + corporation_id: z.number().int(), + alliance_id: z.number().int().optional(), + }), +); +const labelsSchema = z.array( + z.object({ label_id: z.number().int(), label_name: z.string() }), +); +const contactsSchema = z.array( + z.object({ + contact_id: z.number().int(), + contact_type: z.string(), + standing: z.number(), + label_ids: z.array(z.number().int()).nullish(), + }), +); + +export type Affiliation = { + characterId: number; + corporationId: number; + allianceId: number | null; +}; +export type EsiContact = { + contactId: number; + contactType: string; + standing: number; + labelIds: number[]; +}; + +export interface EsiClientOptions { + fetchImpl?: typeof fetch; + now?: () => number; + sleep?: (ms: number) => Promise; + /** Pause when the remaining ESI error budget is at or below this. */ + errorBudgetFloor?: number; +} + +export function createEsiClient(opts: EsiClientOptions = {}) { + const fetchImpl = opts.fetchImpl ?? fetch; + const now = opts.now ?? Date.now; + const sleep = + opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const floor = opts.errorBudgetFloor ?? 5; + + // ESI etiquette: honor X-ESI-Error-Limit-Remain/Reset across all calls. + let remain = Number.POSITIVE_INFINITY; + let resetAt = 0; // epoch ms + + async function request( + path: string, + init: RequestInit & { accessToken?: string } = {}, + ): Promise { + if (remain <= floor && resetAt > now()) { + await sleep(resetAt - now()); + remain = Number.POSITIVE_INFINITY; + } + const headers: Record = { + accept: "application/json", + ...(init.headers as Record | undefined), + }; + if (init.accessToken) headers.authorization = `Bearer ${init.accessToken}`; + const res = await fetchImpl(`${ESI_BASE}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(30_000), + }); + const remainHeader = res.headers.get("x-esi-error-limit-remain"); + const resetHeader = res.headers.get("x-esi-error-limit-reset"); + if (remainHeader !== null) remain = Number(remainHeader); + if (resetHeader !== null) resetAt = now() + Number(resetHeader) * 1000; + if (!res.ok) { + const body = (await res.json().catch(() => undefined)) as + | { error?: string } + | undefined; + throw new EsiError( + `ESI ${init.method ?? "GET"} ${path} failed (${res.status}${body?.error ? `: ${body.error}` : ""})`, + res.status, + classifyEsiError(res.status, body), + ); + } + return res; + } + + async function postAffiliation(ids: number[]): Promise { + if (ids.length === 0) return []; + if (ids.length > AFFILIATION_MAX) { + throw new Error(`postAffiliation: max ${AFFILIATION_MAX} ids per call`); + } + const res = await request("/characters/affiliation/", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(ids), + }); + return affiliationSchema.parse(await res.json()).map((a) => ({ + characterId: a.character_id, + corporationId: a.corporation_id, + allianceId: a.alliance_id ?? null, + })); + } + + async function getContactLabels( + characterId: number, + accessToken: string, + ): Promise> { + const res = await request(`/characters/${characterId}/contacts/labels/`, { + accessToken, + }); + return labelsSchema + .parse(await res.json()) + .map((l) => ({ labelId: l.label_id, labelName: l.label_name })); + } + + /** Reads ALL pages; any page failure rejects the whole call. */ + async function getAllContacts( + characterId: number, + accessToken: string, + ): Promise { + const first = await request(`/characters/${characterId}/contacts/?page=1`, { + accessToken, + }); + const pages = Number(first.headers.get("x-pages") ?? "1"); + const raw = contactsSchema.parse(await first.json()).slice(); + for (let page = 2; page <= pages; page++) { + const res = await request( + `/characters/${characterId}/contacts/?page=${page}`, + { accessToken }, + ); + raw.push(...contactsSchema.parse(await res.json())); + } + return raw.map((c) => ({ + contactId: c.contact_id, + contactType: c.contact_type, + standing: c.standing, + labelIds: c.label_ids ?? [], + })); + } + + function contactWriteParams(standing: number, labelIds: number[]): string { + const params = new URLSearchParams({ standing: String(standing) }); + for (const l of labelIds) params.append("label_ids", String(l)); + return params.toString(); + } + + async function writeContacts( + method: "POST" | "PUT", + characterId: number, + accessToken: string, + contactIds: number[], + standing: number, + labelIds: number[], + ): Promise { + for (const ids of chunk(contactIds, WRITE_CHUNK)) { + await request( + `/characters/${characterId}/contacts/?${contactWriteParams(standing, labelIds)}`, + { + method, + accessToken, + headers: { "content-type": "application/json" }, + body: JSON.stringify(ids), + }, + ); + } + } + + return { + postAffiliation, + getContactLabels, + getAllContacts, + addContacts: ( + characterId: number, + accessToken: string, + contactIds: number[], + standing: number, + labelIds: number[], + ) => writeContacts("POST", characterId, accessToken, contactIds, standing, labelIds), + editContacts: ( + characterId: number, + accessToken: string, + contactIds: number[], + standing: number, + labelIds: number[], + ) => writeContacts("PUT", characterId, accessToken, contactIds, standing, labelIds), + deleteContacts: async ( + characterId: number, + accessToken: string, + contactIds: number[], + ): Promise => { + for (const ids of chunk(contactIds, DELETE_CHUNK)) { + await request( + `/characters/${characterId}/contacts/?contact_ids=${ids.join(",")}`, + { method: "DELETE", accessToken }, + ); + } + }, + }; +} + +export type EsiClient = ReturnType; +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- tests/esi-client.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/esi/client.ts src/core/chunk.ts tests/esi-client.test.ts +git commit -m "feat: throttled ESI client with fail-closed parsing" +``` + +--- + +### Task 4: Affiliation resolution (chunk + bisect) and tier decision + +**Files:** +- Create: `src/core/affiliation.ts`, `src/core/tier.ts` +- Test: `tests/affiliation.test.ts`, `tests/tier.test.ts` + +**Interfaces:** +- Consumes: `chunk` from `src/core/chunk.ts`; `EsiError`, `Affiliation` from `src/lib/esi/client.ts`. +- Produces: + - `resolveAffiliations(ids: number[], post: (ids: number[]) => Promise): Promise` where `type AffiliationOutcome = { resolved: Map; invalid: number[]; unresolved: number[] }`. Chunks at 500; **bisects ONLY on `EsiError` with status 400** down to single ids (those become `invalid`); any other failure marks the whole chunk `unresolved` (never flagged). Ids a successful response silently omits are `unresolved`. + - `decideTier(input: { tier: "flygd" | "blue" | "green"; tierLocked: boolean; mainConfirmed: boolean; mainInAlliance: boolean }): "flygd" | "green" | null` — null when locked, unconfirmed, or already at the desired tier. Any unlocked account is system-managed, so an unlocked Blue account converges to flygd/green (spec tier state machine). + +- [ ] **Step 1: Write failing tests** + +`tests/affiliation.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { resolveAffiliations } from "@/core/affiliation"; +import { EsiError, type Affiliation } from "@/lib/esi/client"; + +const okFor = (ids: number[]): Affiliation[] => + ids.map((id) => ({ characterId: id, corporationId: id * 10, allianceId: 99000001 })); + +describe("resolveAffiliations", () => { + it("resolves a clean batch", async () => { + const out = await resolveAffiliations([1, 2, 3], async (ids) => okFor(ids)); + expect(out.resolved.size).toBe(3); + expect(out.resolved.get(2)).toEqual({ corporationId: 20, allianceId: 99000001 }); + expect(out.invalid).toEqual([]); + expect(out.unresolved).toEqual([]); + }); + + it("submits in chunks of at most 500", async () => { + const sizes: number[] = []; + const ids = Array.from({ length: 1100 }, (_, i) => i + 1); + await resolveAffiliations(ids, async (batch) => { + sizes.push(batch.length); + return okFor(batch); + }); + expect(sizes).toEqual([500, 500, 100]); + }); + + it("bisects deterministic 400s down to the bad ids only", async () => { + const bad = new Set([2, 5]); + const post = vi.fn(async (ids: number[]): Promise => { + if (ids.some((id) => bad.has(id))) { + throw new EsiError("bad id", 400, "permanent"); + } + return okFor(ids); + }); + const out = await resolveAffiliations([1, 2, 3, 4, 5, 6], post); + expect([...out.invalid].sort((a, b) => a - b)).toEqual([2, 5]); + expect([...out.resolved.keys()].sort((a, b) => a - b)).toEqual([1, 3, 4, 6]); + expect(out.unresolved).toEqual([]); + }); + + it("NEVER bisects or flags on transient failures", async () => { + const post = vi.fn(async (): Promise => { + throw new EsiError("rate limited", 420, "transient"); + }); + const out = await resolveAffiliations([1, 2, 3], post); + expect(out.invalid).toEqual([]); + expect([...out.unresolved].sort((a, b) => a - b)).toEqual([1, 2, 3]); + expect(post).toHaveBeenCalledTimes(1); // no bisection attempts + }); + + it("treats non-400 permanent errors as unresolved, not invalid", async () => { + const post = async (): Promise => { + throw new EsiError("not found", 404, "permanent"); + }; + const out = await resolveAffiliations([1, 2], post); + expect(out.invalid).toEqual([]); + expect(out.unresolved).toEqual([1, 2]); + }); + + it("marks ids omitted from a successful response as unresolved", async () => { + const out = await resolveAffiliations([1, 2], async () => okFor([1])); + expect([...out.resolved.keys()]).toEqual([1]); + expect(out.unresolved).toEqual([2]); + }); +}); +``` + +`tests/tier.test.ts` (table-driven): + +```ts +import { describe, expect, it } from "vitest"; +import { decideTier } from "@/core/tier"; + +describe("decideTier", () => { + const cases: Array<{ + name: string; + tier: "flygd" | "blue" | "green"; + tierLocked: boolean; + mainConfirmed: boolean; + mainInAlliance: boolean; + expected: "flygd" | "green" | null; + }> = [ + { name: "green + main in alliance → flygd", tier: "green", tierLocked: false, mainConfirmed: true, mainInAlliance: true, expected: "flygd" }, + { name: "flygd + main left alliance → green", tier: "flygd", tierLocked: false, mainConfirmed: true, mainInAlliance: false, expected: "green" }, + { name: "flygd + main in alliance → no change", tier: "flygd", tierLocked: false, mainConfirmed: true, mainInAlliance: true, expected: null }, + { name: "green + main out → no change", tier: "green", tierLocked: false, mainConfirmed: true, mainInAlliance: false, expected: null }, + { name: "unlocked blue converges to flygd", tier: "blue", tierLocked: false, mainConfirmed: true, mainInAlliance: true, expected: "flygd" }, + { name: "unlocked blue converges to green", tier: "blue", tierLocked: false, mainConfirmed: true, mainInAlliance: false, expected: "green" }, + { name: "locked accounts are never touched", tier: "flygd", tierLocked: true, mainConfirmed: true, mainInAlliance: false, expected: null }, + { name: "locked blue stays blue", tier: "blue", tierLocked: true, mainConfirmed: true, mainInAlliance: true, expected: null }, + { name: "unconfirmed main is never transitioned", tier: "flygd", tierLocked: false, mainConfirmed: false, mainInAlliance: false, expected: null }, + { name: "unconfirmed main never promotes either", tier: "green", tierLocked: false, mainConfirmed: false, mainInAlliance: true, expected: null }, + ]; + for (const c of cases) { + it(c.name, () => { + expect( + decideTier({ + tier: c.tier, + tierLocked: c.tierLocked, + mainConfirmed: c.mainConfirmed, + mainInAlliance: c.mainInAlliance, + }), + ).toBe(c.expected); + }); + } +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- tests/affiliation.test.ts tests/tier.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/core/affiliation.ts`: + +```ts +import { chunk } from "@/core/chunk"; +import { EsiError, type Affiliation } from "@/lib/esi/client"; + +export type AffiliationOutcome = { + resolved: Map; + /** Deterministic 400 on a single id — safe to flag affiliation_invalid. */ + invalid: number[]; + /** Transient or ambiguous failures — never flagged, retried next run. */ + unresolved: number[]; +}; + +const CHUNK_SIZE = 500; + +export async function resolveAffiliations( + ids: number[], + post: (ids: number[]) => Promise, +): Promise { + const out: AffiliationOutcome = { resolved: new Map(), invalid: [], unresolved: [] }; + for (const batch of chunk(ids, CHUNK_SIZE)) { + await resolveChunk(batch, post, out); + } + return out; +} + +async function resolveChunk( + ids: number[], + post: (ids: number[]) => Promise, + out: AffiliationOutcome, +): Promise { + if (ids.length === 0) return; + try { + const rows = await post(ids); + const returned = new Set(); + for (const r of rows) { + returned.add(r.characterId); + out.resolved.set(r.characterId, { + corporationId: r.corporationId, + allianceId: r.allianceId, + }); + } + for (const id of ids) if (!returned.has(id)) out.unresolved.push(id); + } catch (err) { + // Bisect ONLY deterministic invalid-request responses. Anything else + // (420/5xx/network, or odd permanent statuses) must never flag characters. + if (err instanceof EsiError && err.status === 400) { + if (ids.length === 1) { + out.invalid.push(ids[0]); + return; + } + const mid = Math.ceil(ids.length / 2); + await resolveChunk(ids.slice(0, mid), post, out); + await resolveChunk(ids.slice(mid), post, out); + return; + } + out.unresolved.push(...ids); + } +} +``` + +`src/core/tier.ts`: + +```ts +export type Tier = "flygd" | "blue" | "green"; + +/** + * Membership rule: unlocked accounts are system-managed — the desired tier is + * flygd when the main is in the configured alliance, green otherwise (this is + * how an unlocked Blue converges after "return to auto"). Transitions require + * a CONFIRMED affiliation read of the main in this run. Returns the tier to + * set, or null for no change. + */ +export function decideTier(input: { + tier: Tier; + tierLocked: boolean; + mainConfirmed: boolean; + mainInAlliance: boolean; +}): "flygd" | "green" | null { + if (input.tierLocked || !input.mainConfirmed) return null; + const desired = input.mainInAlliance ? "flygd" : "green"; + return input.tier === desired ? null : desired; +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- tests/affiliation.test.ts tests/tier.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/affiliation.ts src/core/tier.ts tests/affiliation.test.ts tests/tier.test.ts +git commit -m "feat: affiliation chunk/bisect resolution and tier decision rule" +``` + +--- + +### Task 5: Token access service + +**Files:** +- Create: `src/services/tokens.ts`, `tests/helpers/seed.ts` +- Test: `tests/tokens.test.ts` + +**Interfaces:** +- Consumes: `decryptToken`/`encryptToken` (`src/lib/crypto.ts`), `refreshEveToken`/`EveSsoError` (`src/lib/esi/sso.ts`), `classifyOAuthError` (`src/core/errors.ts`), `logAudit`. +- Produces: + - `type CharacterTokenRow = { id: number; refreshTokenEnc: string | null; tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing" }` + - `getFreshAccessToken(db: Db, cfg: Config, ch: CharacterTokenRow, fetchImpl?: typeof fetch): Promise` where `type AccessTokenResult = { ok: true; accessToken: string } | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }` — refreshes via `refreshEveToken`, persists the rotated refresh token on success. Permanent OAuth failures AND malformed stored blobs (carry-over: `decryptToken` throws uncleanly) mark `token_status: invalid` + audit `token.invalidated`; transient failures change no state. Used by Tasks 8 and 11. + - Test seed helpers in `tests/helpers/seed.ts`: `seedAccount(db, opts?)` and `seedCharacter(db, cfg, opts)` (exact signatures in code below). Later test tasks consume these. + +- [ ] **Step 1: Write the seed helper and failing test** + +`tests/helpers/seed.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { account, character, discordLink } from "@/db/schema"; +import { encryptToken } from "@/lib/crypto"; + +export async function seedAccount( + db: Db, + opts: { + tier?: "flygd" | "blue" | "green"; + tierLocked?: boolean; + discordUserId?: string; + } = {}, +) { + const [acc] = await db + .insert(account) + .values({ tier: opts.tier ?? "green", tierLocked: opts.tierLocked ?? false }) + .returning(); + if (opts.discordUserId) { + await db + .insert(discordLink) + .values({ accountId: acc.id, discordUserId: opts.discordUserId }); + } + return acc; +} + +export async function seedCharacter( + db: Db, + cfg: Config, + opts: { + id: number; + accountId: string; + name?: string; + ownerHash?: string; + /** null → no stored token; otherwise encrypted with the test key. */ + refreshToken?: string | null; + scopes?: string[]; + tokenStatus?: "valid" | "invalid" | "needs_reauth" | "missing"; + /** Also set as the account's main character. */ + main?: boolean; + allianceId?: number | null; + affiliationInvalid?: boolean; + }, +) { + const [ch] = await db + .insert(character) + .values({ + id: opts.id, + accountId: opts.accountId, + name: opts.name ?? `Char ${opts.id}`, + ownerHash: opts.ownerHash ?? `oh-${opts.id}`, + refreshTokenEnc: + opts.refreshToken === null + ? null + : encryptToken(opts.refreshToken ?? "refresh", cfg.tokenEncryptionKey), + scopes: opts.scopes ?? [...cfg.eveSso.scopes], + tokenStatus: opts.tokenStatus ?? "valid", + allianceId: opts.allianceId ?? null, + affiliationInvalid: opts.affiliationInvalid ?? false, + }) + .returning(); + if (opts.main) { + await db + .update(account) + .set({ mainCharacterId: opts.id }) + .where(eq(account.id, opts.accountId)); + } + return ch; +} +``` + +`tests/tokens.test.ts`: + +```ts +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { auditLog, character } from "@/db/schema"; +import { decryptToken } from "@/lib/crypto"; +import { getFreshAccessToken } from "@/services/tokens"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const tokenJson = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +async function seed(opts: Partial[2]> = {}) { + const acc = await seedAccount(ctx.db); + return seedCharacter(ctx.db, cfg, { id: 90000001, accountId: acc.id, ...opts }); +} + +async function getChar(id: number) { + const rows = await ctx.db.select().from(character).where(eq(character.id, id)); + return rows[0]; +} + +describe("getFreshAccessToken", () => { + it("returns the access token and persists the rotated refresh token", async () => { + const ch = await seed({ refreshToken: "old-rt" }); + const fetchImpl = (async () => + tokenJson({ access_token: "new-at", refresh_token: "new-rt" })) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); + expect(r).toEqual({ ok: true, accessToken: "new-at" }); + const updated = await getChar(90000001); + expect(decryptToken(updated.refreshTokenEnc as string, cfg.tokenEncryptionKey)).toBe("new-rt"); + }); + + it("marks token invalid + audits on permanent OAuth errors", async () => { + const ch = await seed({}); + const fetchImpl = (async () => + tokenJson({ error: "invalid_grant" }, 400)) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); + expect(r).toMatchObject({ ok: false, reason: "invalid" }); + expect((await getChar(90000001)).tokenStatus).toBe("invalid"); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.invalidated")).toBe(true); + }); + + it("changes NO state on transient errors", async () => { + const ch = await seed({}); + const fetchImpl = (async () => + tokenJson({ error: "temporarily_unavailable" }, 503)) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); + expect(r).toMatchObject({ ok: false, reason: "transient" }); + expect((await getChar(90000001)).tokenStatus).toBe("valid"); + }); + + it("maps a malformed stored blob to a clean invalid (carry-over)", async () => { + const ch = await seed({}); + await ctx.db + .update(character) + .set({ refreshTokenEnc: "not.a.blob" }) + .where(eq(character.id, 90000001)); + const r = await getFreshAccessToken( + ctx.db, + cfg, + { ...ch, refreshTokenEnc: "not.a.blob" }, + (async () => tokenJson({})) as typeof fetch, + ); + expect(r).toMatchObject({ ok: false, reason: "invalid", detail: "malformed_token_blob" }); + expect((await getChar(90000001)).tokenStatus).toBe("invalid"); + }); + + it("returns no_token for missing or already-invalid tokens", async () => { + const ch = await seed({ refreshToken: null, tokenStatus: "missing" }); + const r = await getFreshAccessToken(ctx.db, cfg, ch, (async () => + tokenJson({})) as typeof fetch); + expect(r).toEqual({ ok: false, reason: "no_token" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/tokens.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +`src/services/tokens.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character } from "@/db/schema"; +import { classifyOAuthError } from "@/core/errors"; +import { decryptToken, encryptToken } from "@/lib/crypto"; +import { EveSsoError, refreshEveToken } from "@/lib/esi/sso"; +import { logAudit } from "@/services/audit"; + +export type CharacterTokenRow = { + id: number; + refreshTokenEnc: string | null; + tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; +}; + +export type AccessTokenResult = + | { ok: true; accessToken: string } + | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }; + +async function markInvalid(db: Db, characterId: number, reason: string): Promise { + await db.transaction(async (tx) => { + await tx + .update(character) + .set({ tokenStatus: "invalid" }) + .where(eq(character.id, characterId)); + await logAudit(tx, { + actor: "system", + action: "token.invalidated", + target: String(characterId), + details: { reason }, + }); + }); +} + +/** + * Refreshes the character's token and persists the rotated refresh token. + * Permanent OAuth failures — and malformed stored blobs — mark token_status + * invalid; transient failures change no state (spec: Error handling). + */ +export async function getFreshAccessToken( + db: Db, + cfg: Config, + ch: CharacterTokenRow, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!ch.refreshTokenEnc || ch.tokenStatus === "invalid" || ch.tokenStatus === "missing") { + return { ok: false, reason: "no_token" }; + } + let refreshToken: string; + try { + refreshToken = decryptToken(ch.refreshTokenEnc, cfg.tokenEncryptionKey); + } catch { + await markInvalid(db, ch.id, "malformed_token_blob"); + return { ok: false, reason: "invalid", detail: "malformed_token_blob" }; + } + try { + const r = await refreshEveToken(cfg, refreshToken, fetchImpl); + await db + .update(character) + .set({ refreshTokenEnc: encryptToken(r.refreshToken, cfg.tokenEncryptionKey) }) + .where(eq(character.id, ch.id)); + return { ok: true, accessToken: r.accessToken }; + } catch (err) { + if ( + err instanceof EveSsoError && + classifyOAuthError(err.oauthError, err.status) === "permanent" + ) { + await markInvalid(db, ch.id, err.oauthError ?? `status_${err.status}`); + return { ok: false, reason: "invalid", detail: err.oauthError }; + } + return { + ok: false, + reason: "transient", + detail: err instanceof Error ? err.message : String(err), + }; + } +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `npm test -- tests/tokens.test.ts` +Expected: PASS. (Note: the transient case relies on `classifyOAuthError("temporarily_unavailable", 503)` → transient — already covered by `tests/errors.test.ts`.) + +- [ ] **Step 5: Commit** + +```bash +git add src/services/tokens.ts tests/tokens.test.ts tests/helpers/seed.ts +git commit -m "feat: token refresh service with permanent/transient classification" +``` + +--- + +### Task 6: Membership verification job + +**Files:** +- Create: `src/jobs/membership.ts` +- Test: `tests/membership-job.test.ts` + +**Interfaces:** +- Consumes: `resolveAffiliations`, `decideTier`, `EsiClient` (`postAffiliation` only), `runJob`/`JobResult`/`JobRetryError`, `logAudit`, `enqueueSync`. +- Produces: `runMembershipJob(deps: { db: Db; cfg: Config; esi: Pick }, opts?: { accountId?: string; recheckInvalid?: boolean }): Promise` — job type `"membership"`. Behavior: + - Refreshes affiliation columns for all resolved characters; flags deterministic-400 ids `affiliation_invalid` (audit `character.affiliation_invalid` on new flags); resolved ids clear the flag (weekly recheck / admin recheck pass `recheckInvalid: true` to include flagged ids). + - Tier pass skips `tier_locked` and null-main accounts; transitions only on a confirmed read of the MAIN; each transition commits tier update + audit `tier.changed` + `outbox` row in ONE transaction with the account row locked and re-checked. + - Any `unresolved` ids → `partial` + retry (throws `JobRetryError`); otherwise `ok`. Counts: `checked`, `resolved`, `invalid`, `unresolved`, `promoted`, `demoted`. + +- [ ] **Step 1: Write failing test** + +`tests/membership-job.test.ts`: + +```ts +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { account, auditLog, character, outbox } from "@/db/schema"; +import { runMembershipJob } from "@/jobs/membership"; +import { EsiError, type Affiliation } from "@/lib/esi/client"; +import { JobRetryError } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); // allianceId 99000001 + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +/** ESI fake: every id resolves into the given alliance (or none). */ +const esiWith = (alliances: Record) => ({ + postAffiliation: async (ids: number[]): Promise => + ids.map((id) => ({ + characterId: id, + corporationId: 1000, + allianceId: alliances[id] ?? null, + })), +}); + +async function getAccount(id: string) { + const rows = await ctx.db.select().from(account).where(eq(account.id, id)); + return rows[0]; +} + +describe("runMembershipJob", () => { + it("promotes green → flygd on a confirmed main in alliance, transactionally with the outbox row", async () => { + const acc = await seedAccount(ctx.db, { tier: "green" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const result = await runMembershipJob( + { db: ctx.db, cfg, esi: esiWith({ 1: 99000001 }) }, + ); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ promoted: 1, demoted: 0 }); + const after = await getAccount(acc.id); + expect(after.tier).toBe("flygd"); + expect(after.tierChangedBy).toBe("system"); + const outboxRows = await ctx.db.select().from(outbox); + expect(outboxRows.map((r) => r.payload)).toContainEqual({ + kind: "account", + accountId: acc.id, + }); + const audits = await ctx.db.select().from(auditLog); + expect( + audits.some((a) => a.action === "tier.changed" && a.target === acc.id), + ).toBe(true); + }); + + it("demotes flygd → green when the main left the alliance", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id, main: true }); + await runMembershipJob({ db: ctx.db, cfg, esi: esiWith({ 2: null }) }); + expect((await getAccount(acc.id)).tier).toBe("green"); + }); + + it("never touches tier_locked accounts", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd", tierLocked: true }); + await seedCharacter(ctx.db, cfg, { id: 3, accountId: acc.id, main: true }); + await runMembershipJob({ db: ctx.db, cfg, esi: esiWith({ 3: null }) }); + expect((await getAccount(acc.id)).tier).toBe("flygd"); + }); + + it("skips null-main accounts", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 4, accountId: acc.id }); // not main + await runMembershipJob({ db: ctx.db, cfg, esi: esiWith({ 4: null }) }); + expect((await getAccount(acc.id)).tier).toBe("flygd"); + }); + + it("leaves accounts with unresolved mains untouched and retries", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 5, accountId: acc.id, main: true }); + const esi = { + postAffiliation: async (): Promise => { + throw new EsiError("esi down", 503, "transient"); + }, + }; + await expect( + runMembershipJob({ db: ctx.db, cfg, esi }), + ).rejects.toBeInstanceOf(JobRetryError); + expect((await getAccount(acc.id)).tier).toBe("flygd"); // an ESI outage can never mass-demote + }); + + it("flags only bisected 400 ids as affiliation_invalid and audits them", async () => { + const acc = await seedAccount(ctx.db, { tier: "green" }); + await seedCharacter(ctx.db, cfg, { id: 6, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 7, accountId: acc.id }); + const esi = { + postAffiliation: async (ids: number[]): Promise => { + if (ids.includes(7)) throw new EsiError("bad id", 400, "permanent"); + return ids.map((id) => ({ characterId: id, corporationId: 1, allianceId: 99000001 })); + }, + }; + const result = await runMembershipJob({ db: ctx.db, cfg, esi }); + expect(result.counts).toMatchObject({ invalid: 1, promoted: 1 }); + const rows = await ctx.db.select().from(character).where(eq(character.id, 7)); + expect(rows[0].affiliationInvalid).toBe(true); + const six = await ctx.db.select().from(character).where(eq(character.id, 6)); + expect(six[0].affiliationInvalid).toBe(false); + expect(six[0].allianceId).toBe(99000001); + }); + + it("excludes flagged characters unless recheckInvalid is set", async () => { + const acc = await seedAccount(ctx.db); + await seedCharacter(ctx.db, cfg, { id: 8, accountId: acc.id, affiliationInvalid: true }); + const seen: number[][] = []; + const esi = { + postAffiliation: async (ids: number[]): Promise => { + seen.push(ids); + return ids.map((id) => ({ characterId: id, corporationId: 1, allianceId: null })); + }, + }; + await runMembershipJob({ db: ctx.db, cfg, esi }); + expect(seen.flat()).not.toContain(8); + await runMembershipJob({ db: ctx.db, cfg, esi }, { recheckInvalid: true }); + expect(seen.flat()).toContain(8); + // a successful recheck clears the flag + const rows = await ctx.db.select().from(character).where(eq(character.id, 8)); + expect(rows[0].affiliationInvalid).toBe(false); + }); + + it("scopes to one account when accountId is passed", async () => { + const a1 = await seedAccount(ctx.db, { tier: "green" }); + const a2 = await seedAccount(ctx.db, { tier: "green" }); + await seedCharacter(ctx.db, cfg, { id: 10, accountId: a1.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 11, accountId: a2.id, main: true }); + await runMembershipJob( + { db: ctx.db, cfg, esi: esiWith({ 10: 99000001, 11: 99000001 }) }, + { accountId: a1.id }, + ); + expect((await getAccount(a1.id)).tier).toBe("flygd"); + expect((await getAccount(a2.id)).tier).toBe("green"); // untouched + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/membership-job.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +`src/jobs/membership.ts`: + +```ts +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { account, character } from "@/db/schema"; +import { resolveAffiliations } from "@/core/affiliation"; +import { decideTier } from "@/core/tier"; +import type { EsiClient } from "@/lib/esi/client"; +import { logAudit } from "@/services/audit"; +import { enqueueSync } from "@/services/outbox"; +import { runJob, type JobResult } from "@/services/sync-run"; + +export async function runMembershipJob( + deps: { db: Db; cfg: Config; esi: Pick }, + opts: { accountId?: string; recheckInvalid?: boolean } = {}, +): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "membership", async () => { + const chars = await db + .select({ + id: character.id, + accountId: character.accountId, + affiliationInvalid: character.affiliationInvalid, + }) + .from(character) + .where(opts.accountId ? eq(character.accountId, opts.accountId) : undefined); + // affiliation_invalid ids are excluded from batches; the weekly recheck + // (and the admin recheck button) pass recheckInvalid to include them. + const eligible = chars.filter((c) => opts.recheckInvalid || !c.affiliationInvalid); + const outcome = await resolveAffiliations( + eligible.map((c) => c.id), + (ids) => esi.postAffiliation(ids), + ); + + const checkedAt = new Date(); + for (const [id, aff] of outcome.resolved) { + await db + .update(character) + .set({ + corporationId: aff.corporationId, + allianceId: aff.allianceId, + affiliationCheckedAt: checkedAt, + affiliationInvalid: false, + }) + .where(eq(character.id, id)); + } + if (outcome.invalid.length > 0) { + const alreadyFlagged = new Set( + chars.filter((c) => c.affiliationInvalid).map((c) => c.id), + ); + await db + .update(character) + .set({ affiliationInvalid: true, affiliationCheckedAt: checkedAt }) + .where(inArray(character.id, outcome.invalid)); + for (const id of outcome.invalid.filter((i) => !alreadyFlagged.has(i))) { + await logAudit(db, { + actor: "system", + action: "character.affiliation_invalid", + target: String(id), + }); + } + } + + // Tier pass: skip locked and null-main accounts; transition only on a + // confirmed read of the MAIN in this run (an ESI outage can never demote). + const accounts = await db + .select() + .from(account) + .where( + and( + opts.accountId ? eq(account.id, opts.accountId) : undefined, + eq(account.tierLocked, false), + isNotNull(account.mainCharacterId), + ), + ); + let promoted = 0; + let demoted = 0; + for (const acc of accounts) { + const mainAff = + acc.mainCharacterId === null + ? undefined + : outcome.resolved.get(acc.mainCharacterId); + const next = decideTier({ + tier: acc.tier, + tierLocked: acc.tierLocked, + mainConfirmed: mainAff !== undefined, + mainInAlliance: mainAff?.allianceId === cfg.allianceId, + }); + if (!next) continue; + // State change + downstream job trigger commit in ONE transaction. + const applied = await db.transaction(async (tx) => { + const [locked] = await tx + .select() + .from(account) + .where(eq(account.id, acc.id)) + .for("update"); + if ( + !locked || + locked.tierLocked || + locked.tier === next || + locked.mainCharacterId !== acc.mainCharacterId + ) { + return false; // changed underneath us — leave it to the next run + } + await tx + .update(account) + .set({ tier: next, tierChangedAt: new Date(), tierChangedBy: "system" }) + .where(eq(account.id, acc.id)); + await logAudit(tx, { + actor: "system", + action: "tier.changed", + target: acc.id, + details: { + from: locked.tier, + to: next, + cause: next === "flygd" ? "main joined alliance" : "main left alliance", + }, + }); + await enqueueSync(tx, { kind: "account", accountId: acc.id }); + return true; + }); + if (!applied) continue; + if (next === "flygd") promoted++; + else demoted++; + } + + const counts = { + checked: eligible.length, + resolved: outcome.resolved.size, + invalid: outcome.invalid.length, + unresolved: outcome.unresolved.length, + promoted, + demoted, + }; + if (outcome.unresolved.length > 0) { + return { + status: "partial", + errorSummary: `${outcome.unresolved.length} characters unresolved (transient)`, + counts, + retry: true, + }; + } + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `npm test -- tests/membership-job.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/jobs/membership.ts tests/membership-job.test.ts +git commit -m "feat: membership verification job with confirmed-read tier transitions" +``` + +--- + +### Task 7: Desired-set service and contacts diff + +**Files:** +- Create: `src/services/desired.ts`, `src/core/contacts-diff.ts` +- Test: `tests/desired.test.ts`, `tests/contacts-diff.test.ts` + +**Interfaces:** +- Consumes: schema tables; `Dbx`. +- Produces: + - `type FlygdCharacter = { characterId: number; accountId: string; name: string; refreshTokenEnc: string | null; tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; scopes: string[] }` + - `getFlygdCharacters(dbx: Dbx): Promise` — every character of every FlyGD account: the derived desired standings/ACL set (Green/Blue accounts fall out; nothing is deleted from the DB). + - `type ContactState = { contactId: number; standing: number; labelIds: number[] }` + - `diffContacts(input: { desiredIds: number[]; standing: number; labelId: number; contacts: ContactState[] }): { add: number[]; update: Array<{ contactId: number; labelIds: number[] }>; remove: number[] }` — `desiredIds` must already exclude the target character itself. Label-ownership policy (accepted-destructive, aa-standingssync precedent): desired ids absent → `add`; present but missing our label or wrong standing → `update` with the PRESERVED label union (ESI PUT replaces `label_ids` wholesale — never clobber personal labels); contacts carrying our label but not desired → `remove`; contacts never carrying our label are NEVER touched. + +- [ ] **Step 1: Write failing tests** + +`tests/contacts-diff.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { diffContacts, type ContactState } from "@/core/contacts-diff"; + +const LABEL = 7; + +const contact = ( + contactId: number, + standing: number, + labelIds: number[] = [], +): ContactState => ({ contactId, standing, labelIds }); + +describe("diffContacts", () => { + it("adds desired ids that are absent", () => { + const d = diffContacts({ desiredIds: [1, 2], standing: 5, labelId: LABEL, contacts: [] }); + expect(d).toEqual({ add: [1, 2], update: [], remove: [] }); + }); + + it("leaves correct labeled contacts alone", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [contact(1, 5, [LABEL])], + }); + expect(d).toEqual({ add: [], update: [], remove: [] }); + }); + + it("takes over an existing personal contact, preserving its labels", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [contact(1, 0, [3])], + }); + expect(d).toEqual({ + add: [], + update: [{ contactId: 1, labelIds: [3, LABEL] }], + remove: [], + }); + }); + + it("re-asserts standing on labeled contacts without duplicating the label", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [contact(1, -10, [LABEL])], + }); + expect(d).toEqual({ + add: [], + update: [{ contactId: 1, labelIds: [LABEL] }], + remove: [], + }); + }); + + it("removes only OUR labeled contacts that left the desired set", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [ + contact(1, 5, [LABEL]), + contact(2, 5, [LABEL]), // ours, no longer desired → delete + contact(3, 10, []), // personal, unlabeled → NEVER touched + contact(4, -5, [9]), // personal, other label → NEVER touched + ], + }); + expect(d).toEqual({ add: [], update: [], remove: [2] }); + }); +}); +``` + +`tests/desired.test.ts`: + +```ts +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { sql } from "drizzle-orm"; +import { getFlygdCharacters } from "@/services/desired"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +describe("getFlygdCharacters", () => { + it("returns every character of every flygd account and nothing else", async () => { + const flygd = await seedAccount(ctx.db, { tier: "flygd" }); + const green = await seedAccount(ctx.db, { tier: "green" }); + const blue = await seedAccount(ctx.db, { tier: "blue", tierLocked: true }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: flygd.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: flygd.id }); // alt counts too + await seedCharacter(ctx.db, cfg, { id: 3, accountId: green.id }); + await seedCharacter(ctx.db, cfg, { id: 4, accountId: blue.id }); + const rows = await getFlygdCharacters(ctx.db); + expect(rows.map((r) => r.characterId).sort((a, b) => a - b)).toEqual([1, 2]); + expect(rows[0]).toMatchObject({ accountId: flygd.id, tokenStatus: "valid" }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- tests/contacts-diff.test.ts tests/desired.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/core/contacts-diff.ts`: + +```ts +export type ContactState = { + contactId: number; + standing: number; + labelIds: number[]; +}; + +export type ContactsDiff = { + add: number[]; + update: Array<{ contactId: number; labelIds: number[] }>; + remove: number[]; +}; + +/** + * Label-ownership policy (accepted-destructive, aa-standingssync precedent): + * the app owns `labelId` outright. Desired ids are added, or taken over if + * they already exist as personal contacts (standing re-asserted, our label + * added while PRESERVING existing labels — ESI PUT replaces label_ids + * wholesale). Contacts carrying our label that leave the desired set are + * deleted entirely. Contacts never carrying our label are never modified. + * `desiredIds` must already exclude the target character itself. + */ +export function diffContacts(input: { + desiredIds: number[]; + standing: number; + labelId: number; + contacts: ContactState[]; +}): ContactsDiff { + const desired = new Set(input.desiredIds); + const byId = new Map(input.contacts.map((c) => [c.contactId, c])); + const add: number[] = []; + const update: Array<{ contactId: number; labelIds: number[] }> = []; + for (const id of input.desiredIds) { + const existing = byId.get(id); + if (!existing) { + add.push(id); + continue; + } + const hasLabel = existing.labelIds.includes(input.labelId); + if (!hasLabel || existing.standing !== input.standing) { + update.push({ + contactId: id, + labelIds: hasLabel ? existing.labelIds : [...existing.labelIds, input.labelId], + }); + } + } + const remove = input.contacts + .filter((c) => c.labelIds.includes(input.labelId) && !desired.has(c.contactId)) + .map((c) => c.contactId); + return { add, update, remove }; +} +``` + +`src/services/desired.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Dbx } from "@/db"; +import { account, character } from "@/db/schema"; + +export type FlygdCharacter = { + characterId: number; + accountId: string; + name: string; + refreshTokenEnc: string | null; + tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; + scopes: string[]; +}; + +/** + * The derived desired set: every character of every FlyGD account (spec: Data + * model → Derived). Green/Blue accounts simply fall out; nothing is deleted. + */ +export async function getFlygdCharacters(dbx: Dbx): Promise { + return dbx + .select({ + characterId: character.id, + accountId: character.accountId, + name: character.name, + refreshTokenEnc: character.refreshTokenEnc, + tokenStatus: character.tokenStatus, + scopes: character.scopes, + }) + .from(character) + .innerJoin(account, eq(character.accountId, account.id)) + .where(eq(account.tier, "flygd")); +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- tests/contacts-diff.test.ts tests/desired.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/contacts-diff.ts src/services/desired.ts tests/contacts-diff.test.ts tests/desired.test.ts +git commit -m "feat: desired-set query and label-scoped contacts diff" +``` + +--- + +### Task 8: Contact push job + +**Files:** +- Create: `src/jobs/contacts.ts` +- Test: `tests/contacts-job.test.ts` + +**Interfaces:** +- Consumes: `getFlygdCharacters`, `diffContacts`, `getFreshAccessToken`, `EsiClient` (contact methods), `runJob`, `contactSyncState` table. +- Produces: + - `const CONTACT_SCOPES = ["esi-characters.read_contacts.v1", "esi-characters.write_contacts.v1"]` + - `canPushContacts(ch: Pick): boolean` — per-job scope gate: token present, status not invalid/missing, and BOTH contact scopes granted. `needs_reauth` (missing some unrelated scope) is NOT a blocker. + - `type ContactsEsi = Pick` + - `runContactsJob(deps: { db: Db; cfg: Config; esi: ContactsEsi; fetchImpl?: typeof fetch }): Promise` — job type `"contacts"`, global reconciliation over all push targets. Per character: labels first (missing configured label → record `missing_label`, skip ALL writes); read ALL contact pages before any destructive diff (any failure aborts that character); apply diff (updates grouped by preserved label set); record `contact_sync_state.last_result` (`ok` / `missing_label` / `token_invalid` / `token_refresh_failed` / `needs_reauth` / `sync_failed`), `last_synced_at` only on `ok`. A 403-scope `EsiError` (`kind === "needs_reauth"`) also sets the character's `token_status` to `needs_reauth`. Transient failures → `partial` + retry; per-character permanent failures → `partial` without retry; all clean → `ok`. + +- [ ] **Step 1: Write failing test** + +`tests/contacts-job.test.ts`: + +```ts +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { character, contactSyncState } from "@/db/schema"; +import { canPushContacts, runContactsJob, type ContactsEsi } from "@/jobs/contacts"; +import { EsiError, type EsiContact } from "@/lib/esi/client"; +import { JobRetryError } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); // label "flygd", standing 5 +const LABEL_ID = 77; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const okToken = (async () => + new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt2" }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + +type Calls = { + adds: Array<{ characterId: number; ids: number[]; labelIds: number[] }>; + edits: Array<{ characterId: number; ids: number[]; labelIds: number[] }>; + deletes: Array<{ characterId: number; ids: number[] }>; +}; + +/** Fake ESI: per-character labels and contacts; records all writes. */ +function fakeEsi(perChar: { + labels?: Record>; + contacts?: Record; +}): { esi: ContactsEsi; calls: Calls } { + const calls: Calls = { adds: [], edits: [], deletes: [] }; + const esi: ContactsEsi = { + getContactLabels: async (characterId) => + perChar.labels?.[characterId] ?? [{ labelId: LABEL_ID, labelName: "flygd" }], + getAllContacts: async (characterId) => { + const c = perChar.contacts?.[characterId] ?? []; + if (c === "fail") throw new EsiError("page read failed", 500, "transient"); + return c; + }, + addContacts: async (characterId, _at, ids, _standing, labelIds) => { + calls.adds.push({ characterId, ids, labelIds }); + }, + editContacts: async (characterId, _at, ids, _standing, labelIds) => { + calls.edits.push({ characterId, ids, labelIds }); + }, + deleteContacts: async (characterId, _at, ids) => { + calls.deletes.push({ characterId, ids }); + }, + }; + return { esi, calls }; +} + +async function lastResult(characterId: number) { + const rows = await ctx.db + .select() + .from(contactSyncState) + .where(eq(contactSyncState.characterId, characterId)); + return rows[0]; +} + +const labeled = (contactId: number, standing = 5): EsiContact => ({ + contactId, + contactType: "character", + standing, + labelIds: [LABEL_ID], +}); + +describe("canPushContacts", () => { + const base = { + refreshTokenEnc: "enc", + tokenStatus: "valid" as const, + scopes: [...cfg.eveSso.scopes], + }; + it("gates on token presence, status, and BOTH contact scopes", () => { + expect(canPushContacts(base)).toBe(true); + expect(canPushContacts({ ...base, refreshTokenEnc: null })).toBe(false); + expect(canPushContacts({ ...base, tokenStatus: "invalid" })).toBe(false); + expect(canPushContacts({ ...base, tokenStatus: "missing" })).toBe(false); + expect( + canPushContacts({ ...base, scopes: ["esi-characters.read_contacts.v1"] }), + ).toBe(false); + }); + it("needs_reauth with contact scopes granted is NOT a blocker", () => { + expect(canPushContacts({ ...base, tokenStatus: "needs_reauth" })).toBe(true); + }); +}); + +describe("runContactsJob", () => { + it("fully reconciles: add, take over, remove ours, never touch unlabeled", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const acc2 = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 3, accountId: acc2.id, main: true }); + // Only character 1 has interesting contacts; keep the others empty. + const { esi, calls } = fakeEsi({ + contacts: { + 1: [ + labeled(3), // desired, correct → untouched + labeled(99), // ours, no longer desired → delete + { contactId: 2, contactType: "character", standing: 0, labelIds: [5] }, // desired, personal → take over + { contactId: 500, contactType: "character", standing: 10, labelIds: [] }, // unlabeled → never touched + ], + }, + }); + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("ok"); + // character 1's desired set excludes itself: {2, 3} + expect(calls.edits).toContainEqual({ characterId: 1, ids: [2], labelIds: [5, LABEL_ID] }); + expect(calls.deletes).toContainEqual({ characterId: 1, ids: [99] }); + expect(calls.adds.filter((c) => c.characterId === 1)).toEqual([]); + // characters 2 and 3 each get the other two added + expect(calls.adds).toContainEqual({ characterId: 2, ids: [1, 3], labelIds: [LABEL_ID] }); + expect(calls.adds).toContainEqual({ characterId: 3, ids: [1, 2], labelIds: [LABEL_ID] }); + expect((await lastResult(1))?.lastResult).toBe("ok"); + expect((await lastResult(1))?.lastSyncedAt).not.toBeNull(); + }); + + it("records missing_label and skips ALL writes for that character", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({ + labels: { 1: [{ labelId: 9, labelName: "other" }] }, + }); + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("ok"); // missing_label is a recorded skip, not a failure + expect((await lastResult(1))?.lastResult).toBe("missing_label"); + expect(calls.adds.filter((c) => c.characterId === 1)).toEqual([]); + expect(calls.deletes.filter((c) => c.characterId === 1)).toEqual([]); + }); + + it("aborts a character on a partial contact read — no destructive writes", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({ contacts: { 1: "fail" } }); + await expect( + runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }), + ).rejects.toBeInstanceOf(JobRetryError); // transient → retry + expect((await lastResult(1))?.lastResult).toBe("sync_failed"); + expect(calls.deletes.filter((c) => c.characterId === 1)).toEqual([]); + // the other character still synced (partial-failure isolation) + expect((await lastResult(2))?.lastResult).toBe("ok"); + }); + + it("skips non-pushable characters but keeps them in the desired set", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id, tokenStatus: "invalid" }); + const { esi, calls } = fakeEsi({}); + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("ok"); + // 2 is not pushed to… + expect(calls.adds.filter((c) => c.characterId === 2)).toEqual([]); + // …but 2 is still in 1's desired set + expect(calls.adds).toContainEqual({ characterId: 1, ids: [2], labelIds: [LABEL_ID] }); + }); + + it("needs_reauth with contact scopes still syncs (per-job gating)", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, + accountId: acc.id, + main: true, + tokenStatus: "needs_reauth", // e.g. missing an unrelated new scope + }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({}); + await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(calls.adds).toContainEqual({ characterId: 1, ids: [2], labelIds: [LABEL_ID] }); + }); + + it("marks the character needs_reauth when ESI rejects the scope", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const esi: ContactsEsi = { + ...fakeEsi({}).esi, + getContactLabels: async (characterId) => { + if (characterId === 1) { + throw new EsiError("token has no scope", 403, "needs_reauth"); + } + return [{ labelId: LABEL_ID, labelName: "flygd" }]; + }, + }; + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("partial"); + expect((await lastResult(1))?.lastResult).toBe("needs_reauth"); + const rows = await ctx.db.select().from(character).where(eq(character.id, 1)); + expect(rows[0].tokenStatus).toBe("needs_reauth"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/contacts-job.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +`src/jobs/contacts.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db, Dbx } from "@/db"; +import { character, contactSyncState } from "@/db/schema"; +import { diffContacts } from "@/core/contacts-diff"; +import { EsiError, type EsiClient } from "@/lib/esi/client"; +import { getFlygdCharacters, type FlygdCharacter } from "@/services/desired"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +export const CONTACT_SCOPES = [ + "esi-characters.read_contacts.v1", + "esi-characters.write_contacts.v1", +] as const; + +/** + * Per-job scope gate (spec: needs_reauth is a capability warning, never a + * global blocker): a token missing some unrelated scope still pushes contacts + * as long as BOTH contact scopes are granted and the token isn't dead. + */ +export function canPushContacts( + ch: Pick, +): boolean { + if (!ch.refreshTokenEnc) return false; + if (ch.tokenStatus === "invalid" || ch.tokenStatus === "missing") return false; + return CONTACT_SCOPES.every((s) => ch.scopes.includes(s)); +} + +export type ContactsEsi = Pick< + EsiClient, + "getContactLabels" | "getAllContacts" | "addContacts" | "editContacts" | "deleteContacts" +>; + +async function recordResult( + dbx: Dbx, + characterId: number, + result: string, + synced: boolean, +): Promise { + const set = synced + ? { lastResult: result, lastSyncedAt: new Date() } + : { lastResult: result }; + await dbx + .insert(contactSyncState) + .values({ characterId, ...set }) + .onConflictDoUpdate({ target: contactSyncState.characterId, set }); +} + +export async function runContactsJob(deps: { + db: Db; + cfg: Config; + esi: ContactsEsi; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "contacts", async () => { + const flygd = await getFlygdCharacters(db); + const desiredAll = flygd.map((c) => c.characterId); + const counts = { targets: 0, added: 0, updated: 0, removed: 0, skipped: 0, failed: 0 }; + let transientFailures = 0; + const errors: string[] = []; + + for (const target of flygd) { + if (!canPushContacts(target)) { + counts.skipped++; + continue; + } + counts.targets++; + const token = await getFreshAccessToken( + db, + cfg, + { + id: target.characterId, + refreshTokenEnc: target.refreshTokenEnc, + tokenStatus: target.tokenStatus, + }, + deps.fetchImpl, + ); + if (!token.ok) { + if (token.reason === "transient") { + transientFailures++; + await recordResult(db, target.characterId, "token_refresh_failed", false); + } else { + counts.failed++; + await recordResult(db, target.characterId, "token_invalid", false); + } + continue; + } + try { + // Labels first: ESI cannot create labels, so a missing label is a + // user-remediation state — record it and skip ALL writes (spec job 2). + const labels = await esi.getContactLabels(target.characterId, token.accessToken); + const label = labels.find((l) => l.labelName === cfg.standings.label); + if (!label) { + counts.skipped++; + await recordResult(db, target.characterId, "missing_label", false); + continue; + } + // Read ALL pages before any destructive diff; getAllContacts rejects + // on any page failure, aborting this character's reconciliation. + const contacts = await esi.getAllContacts(target.characterId, token.accessToken); + const diff = diffContacts({ + desiredIds: desiredAll.filter((id) => id !== target.characterId), + standing: cfg.standings.value, + labelId: label.labelId, + contacts, + }); + if (diff.add.length > 0) { + await esi.addContacts( + target.characterId, + token.accessToken, + diff.add, + cfg.standings.value, + [label.labelId], + ); + } + // Group takeovers by their preserved label set — PUT replaces + // label_ids wholesale, so each distinct union is its own call. + const groups = new Map(); + for (const u of diff.update) { + const key = u.labelIds.join(","); + const g = groups.get(key) ?? { labelIds: u.labelIds, ids: [] }; + g.ids.push(u.contactId); + groups.set(key, g); + } + for (const g of groups.values()) { + await esi.editContacts( + target.characterId, + token.accessToken, + g.ids, + cfg.standings.value, + g.labelIds, + ); + } + if (diff.remove.length > 0) { + await esi.deleteContacts(target.characterId, token.accessToken, diff.remove); + } + counts.added += diff.add.length; + counts.updated += diff.update.length; + counts.removed += diff.remove.length; + await recordResult(db, target.characterId, "ok", true); + } catch (err) { + const needsReauth = err instanceof EsiError && err.kind === "needs_reauth"; + const transient = err instanceof EsiError ? err.kind === "transient" : true; + if (needsReauth) { + counts.failed++; + await db + .update(character) + .set({ tokenStatus: "needs_reauth" }) + .where(eq(character.id, target.characterId)); + await recordResult(db, target.characterId, "needs_reauth", false); + } else { + if (transient) transientFailures++; + else counts.failed++; + await recordResult(db, target.characterId, "sync_failed", false); + } + errors.push( + `${target.characterId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (transientFailures > 0 || counts.failed > 0) { + return { + status: "partial", + errorSummary: errors.slice(0, 5).join("; ") || "token failures", + counts, + retry: transientFailures > 0, + }; + } + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `npm test -- tests/contacts-job.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/jobs/contacts.ts tests/contacts-job.test.ts +git commit -m "feat: per-character contact push with label ownership and abort-on-partial-read" +``` + +--- + +### Task 9: Wanderer client, ACL diff, and ACL sync job + +**Files:** +- Create: `src/lib/wanderer/client.ts`, `src/core/acl-diff.ts`, `src/jobs/wanderer.ts` +- Test: `tests/wanderer-client.test.ts`, `tests/acl-diff.test.ts`, `tests/wanderer-job.test.ts` + +**Interfaces:** +- Consumes: `Config["wanderer"]`, `getFlygdCharacters`, `runJob`, `wandererAclObservation` table, `logAudit`. +- Produces: + - `class WandererError extends Error { status?: number; transient: boolean }` (429/5xx/network → transient; other HTTP → permanent). + - `createWandererClient(cfg: Config, fetchImpl?: typeof fetch)` / `type WandererClient` with `getAclMembers(): Promise>`, `addAclMember(characterId: number): Promise`, `removeAclMember(characterId: number): Promise`. **Assumed contract** (verify against the live instance at deploy, Plan 3): `GET|POST {base}/api/acls/{aclId}/members`, `DELETE {base}/api/acls/{aclId}/members/{characterEveId}`, `Authorization: Bearer {apiKey}`, member shape `{ eve_character_id: "digits", role: string }` under `data`. + - `type AclMember = { characterId: number; role: string }`; `diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; remove: number[] }` — **`admin`-role entries are never removed; `manager` entries are removable like anyone else.** + - `runWandererJob(deps: { db: Db; wanderer: WandererClient }): Promise` — job type `"wanderer"`. Read fails → `failed` before ANY mutation (never remove on unknown state), retry per transience. After any mutation (or partial failure), **re-read the ACL and persist THAT read** wholesale into `wanderer_acl_observation`; when nothing was mutated, persist the initial read. If the post-mutation re-read fails, the observation is left untouched (stale-but-honest) and the run is `partial` + retry. Audits `wanderer.added` / `wanderer.removed` per successful mutation. + +- [ ] **Step 1: Write failing tests** + +`tests/acl-diff.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { diffAcl } from "@/core/acl-diff"; + +describe("diffAcl", () => { + it("adds missing desired members and removes undesired ones", () => { + expect( + diffAcl({ + desiredIds: [1, 2], + members: [ + { characterId: 2, role: "member" }, + { characterId: 3, role: "member" }, + ], + }), + ).toEqual({ add: [1], remove: [3] }); + }); + + it("NEVER removes admin-role entries; managers are removable", () => { + expect( + diffAcl({ + desiredIds: [], + members: [ + { characterId: 1, role: "admin" }, + { characterId: 2, role: "manager" }, + { characterId: 3, role: "member" }, + ], + }), + ).toEqual({ add: [], remove: [2, 3] }); + }); + + it("is a no-op when converged", () => { + expect( + diffAcl({ desiredIds: [1], members: [{ characterId: 1, role: "member" }] }), + ).toEqual({ add: [], remove: [] }); + }); +}); +``` + +`tests/wanderer-client.test.ts`: + +```ts +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createWandererClient, WandererError } from "@/lib/wanderer/client"; +import { testConfig } from "./helpers/config"; + +const cfg = testConfig(); // base https://wanderer.example, aclId acl-1 +const MEMBERS = "https://wanderer.example/api/acls/acl-1/members"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("createWandererClient", () => { + it("reads ACL members with bearer auth and parses digit-string ids", async () => { + server.use( + http.get(MEMBERS, ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bearer wkey"); + return HttpResponse.json({ + data: [ + { eve_character_id: "90000001", role: "admin" }, + { eve_character_id: "90000002", role: "member" }, + ], + }); + }), + ); + const w = createWandererClient(cfg); + expect(await w.getAclMembers()).toEqual([ + { characterId: 90000001, role: "admin" }, + { characterId: 90000002, role: "member" }, + ]); + }); + + it("fails closed on malformed member payloads", async () => { + server.use( + http.get(MEMBERS, () => + HttpResponse.json({ data: [{ eve_character_id: "not-digits", role: "x" }] }), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + + it("classifies 5xx as transient and 403 as permanent", async () => { + server.use(http.get(MEMBERS, () => HttpResponse.json({}, { status: 502 }))); + let err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); + expect(err).toBeInstanceOf(WandererError); + expect((err as WandererError).transient).toBe(true); + + server.use(http.get(MEMBERS, () => HttpResponse.json({}, { status: 403 }))); + err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); + expect((err as WandererError).transient).toBe(false); + }); + + it("adds and removes members on the assumed endpoints", async () => { + const posts: unknown[] = []; + let deleted = ""; + server.use( + http.post(MEMBERS, async ({ request }) => { + posts.push(await request.json()); + return HttpResponse.json({}, { status: 201 }); + }), + http.delete(`${MEMBERS}/:id`, ({ params }) => { + deleted = params.id as string; + return HttpResponse.json({}); + }), + ); + const w = createWandererClient(cfg); + await w.addAclMember(90000003); + await w.removeAclMember(90000004); + expect(posts).toEqual([{ member: { eve_character_id: "90000003", role: "member" } }]); + expect(deleted).toBe("90000004"); + }); +}); +``` + +`tests/wanderer-job.test.ts`: + +```ts +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { auditLog, wandererAclObservation } from "@/db/schema"; +import { runWandererJob } from "@/jobs/wanderer"; +import { WandererError, type WandererClient } from "@/lib/wanderer/client"; +import { JobRetryError } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +type Member = { characterId: number; role: string }; + +/** Fake Wanderer with a mutable member list and scriptable failures. */ +function fakeWanderer(initial: Member[], opts: { + failFirstRead?: boolean; + failReRead?: boolean; + failRemoveOf?: number; +} = {}) { + let members = [...initial]; + let reads = 0; + const client: WandererClient = { + getAclMembers: async () => { + reads++; + if (opts.failFirstRead && reads === 1) { + throw new WandererError("read failed", { status: 502, transient: true }); + } + if (opts.failReRead && reads > 1) { + throw new WandererError("re-read failed", { status: 502, transient: true }); + } + return [...members]; + }, + addAclMember: async (id) => { + members.push({ characterId: id, role: "member" }); + }, + removeAclMember: async (id) => { + if (opts.failRemoveOf === id) { + throw new WandererError("remove failed", { status: 500, transient: true }); + } + members = members.filter((m) => m.characterId !== id); + }, + }; + return { client, members: () => members, reads: () => reads }; +} + +async function seedFlygdChar(id: number) { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id, accountId: acc.id, main: true }); +} + +describe("runWandererJob", () => { + it("adds desired, removes undesired (never admins), persists the POST-mutation read", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([ + { characterId: 2, role: "member" }, + { characterId: 3, role: "admin" }, + { characterId: 4, role: "manager" }, + ]); + const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ added: 1, removed: 2 }); + expect(w.reads()).toBe(2); // initial + post-mutation + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => [o.characterId, o.role]).sort()).toEqual([ + [1, "member"], + [3, "admin"], + ]); + const audits = await ctx.db.select().from(auditLog); + expect(audits.filter((a) => a.action === "wanderer.removed")).toHaveLength(2); + expect(audits.filter((a) => a.action === "wanderer.added")).toHaveLength(1); + }); + + it("aborts before ANY mutation when the initial read fails", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 2, role: "member" }], { failFirstRead: true }); + await expect( + runWandererJob({ db: ctx.db, wanderer: w.client }), + ).rejects.toBeInstanceOf(JobRetryError); + expect(w.members()).toEqual([{ characterId: 2, role: "member" }]); // untouched + expect(await ctx.db.select().from(wandererAclObservation)).toEqual([]); + }); + + it("persists the initial read as the observation when nothing needs mutating", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 1, role: "member" }]); + await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(w.reads()).toBe(1); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed).toHaveLength(1); + expect(observed[0].characterId).toBe(1); + }); + + it("still re-reads and persists after a partial mutation failure, then retries", async () => { + await seedFlygdChar(1); + const w = fakeWanderer( + [ + { characterId: 2, role: "member" }, + { characterId: 5, role: "member" }, + ], + { failRemoveOf: 5 }, + ); + await expect( + runWandererJob({ db: ctx.db, wanderer: w.client }), + ).rejects.toBeInstanceOf(JobRetryError); + const observed = await ctx.db.select().from(wandererAclObservation); + // 5's removal failed, so the post-mutation read still contains it — and + // the observation reflects that reality, not the desired state. + expect(observed.map((o) => o.characterId).sort((a, b) => a - b)).toEqual([1, 5]); + }); + + it("leaves the previous observation untouched when the re-read fails", async () => { + await seedFlygdChar(1); + await ctx.db.insert(wandererAclObservation).values({ + characterId: 42, + role: "member", + observedAt: new Date(), + }); + const w = fakeWanderer([{ characterId: 2, role: "member" }], { failReRead: true }); + await expect( + runWandererJob({ db: ctx.db, wanderer: w.client }), + ).rejects.toBeInstanceOf(JobRetryError); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => o.characterId)).toEqual([42]); // stale but honest + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- tests/acl-diff.test.ts tests/wanderer-client.test.ts tests/wanderer-job.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/core/acl-diff.ts`: + +```ts +export type AclMember = { characterId: number; role: string }; + +/** + * Spec job 3: admin-role entries are NEVER removed; manager-role entries are + * removed like anyone else when they leave the desired set. + */ +export function diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { + add: number[]; + remove: number[]; +} { + const desired = new Set(input.desiredIds); + const present = new Set(input.members.map((m) => m.characterId)); + return { + add: input.desiredIds.filter((id) => !present.has(id)), + remove: input.members + .filter((m) => !desired.has(m.characterId) && m.role !== "admin") + .map((m) => m.characterId), + }; +} +``` + +`src/lib/wanderer/client.ts`: + +```ts +import { z } from "zod"; +import type { Config } from "@/config"; + +// ASSUMED CONTRACT — the repo records only base URL / API key / ACL id, not +// the REST shapes. These paths and payloads are pinned by the msw tests and +// MUST be verified against the live Wanderer instance at deploy time (Plan 3). + +export class WandererError extends Error { + status?: number; + transient: boolean; + constructor(message: string, opts: { status?: number; transient: boolean }) { + super(message); + this.status = opts.status; + this.transient = opts.transient; + } +} + +const membersSchema = z.object({ + data: z.array( + z.object({ + eve_character_id: z.string().regex(/^\d+$/), + role: z.string(), + }), + ), +}); + +export type WandererAclMember = { characterId: number; role: string }; + +export function createWandererClient(cfg: Config, fetchImpl: typeof fetch = fetch) { + const base = cfg.wanderer.baseUrl.replace(/\/$/, ""); + const membersPath = `/api/acls/${cfg.wanderer.aclId}/members`; + + async function request(path: string, init: RequestInit = {}): Promise { + let res: Response; + try { + res = await fetchImpl(`${base}${path}`, { + ...init, + headers: { + authorization: `Bearer ${cfg.wanderer.apiKey}`, + "content-type": "application/json", + ...(init.headers as Record | undefined), + }, + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + throw new WandererError( + `wanderer request failed: ${err instanceof Error ? err.message : String(err)}`, + { transient: true }, + ); + } + if (!res.ok) { + throw new WandererError( + `wanderer ${init.method ?? "GET"} ${path} failed (${res.status})`, + { status: res.status, transient: res.status === 429 || res.status >= 500 }, + ); + } + return res; + } + + return { + async getAclMembers(): Promise { + const res = await request(membersPath); + return membersSchema + .parse(await res.json()) + .data.map((m) => ({ characterId: Number(m.eve_character_id), role: m.role })); + }, + async addAclMember(characterId: number): Promise { + await request(membersPath, { + method: "POST", + body: JSON.stringify({ + member: { eve_character_id: String(characterId), role: "member" }, + }), + }); + }, + async removeAclMember(characterId: number): Promise { + await request(`${membersPath}/${characterId}`, { method: "DELETE" }); + }, + }; +} + +export type WandererClient = ReturnType; +``` + +`src/jobs/wanderer.ts`: + +```ts +import type { Db } from "@/db"; +import { wandererAclObservation } from "@/db/schema"; +import { diffAcl } from "@/core/acl-diff"; +import { WandererError, type WandererClient } from "@/lib/wanderer/client"; +import { logAudit } from "@/services/audit"; +import { getFlygdCharacters } from "@/services/desired"; +import { runJob, type JobResult } from "@/services/sync-run"; + +export async function runWandererJob(deps: { + db: Db; + wanderer: WandererClient; +}): Promise { + const { db, wanderer } = deps; + return runJob(db, "wanderer", async () => { + const desiredIds = (await getFlygdCharacters(db)).map((c) => c.characterId); + + // Never remove on unknown state: a failed read aborts before ANY mutation. + let members; + try { + members = await wanderer.getAclMembers(); + } catch (err) { + return { + status: "failed", + errorSummary: `ACL read failed: ${err instanceof Error ? err.message : String(err)}`, + retry: err instanceof WandererError ? err.transient : true, + }; + } + + const diff = diffAcl({ desiredIds, members }); + const errors: string[] = []; + let added = 0; + let removed = 0; + for (const id of diff.add) { + try { + await wanderer.addAclMember(id); + added++; + await logAudit(db, { actor: "system", action: "wanderer.added", target: String(id) }); + } catch (err) { + errors.push(`add ${id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + for (const id of diff.remove) { + try { + await wanderer.removeAclMember(id); + removed++; + await logAudit(db, { actor: "system", action: "wanderer.removed", target: String(id) }); + } catch (err) { + errors.push(`remove ${id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Persist the POST-mutation state (spec: the UI never shows pre-mutation + // state). No mutation → the initial read is already the live state. + let observed: typeof members | null = members; + if (added + removed > 0 || errors.length > 0) { + try { + observed = await wanderer.getAclMembers(); + } catch { + observed = null; // keep the previous observation: stale but honest + } + } + if (observed !== null) { + const rows = observed; + const observedAt = new Date(); + await db.transaction(async (tx) => { + await tx.delete(wandererAclObservation); + if (rows.length > 0) { + await tx.insert(wandererAclObservation).values( + rows.map((m) => ({ characterId: m.characterId, role: m.role, observedAt })), + ); + } + }); + } + + const counts = { added, removed, addFailed: diff.add.length - added, removeFailed: diff.remove.length - removed }; + if (errors.length > 0 || observed === null) { + return { + status: "partial", + errorSummary: [...errors, ...(observed === null ? ["post-mutation re-read failed"] : [])] + .slice(0, 5) + .join("; "), + counts, + retry: true, + }; + } + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- tests/acl-diff.test.ts tests/wanderer-client.test.ts tests/wanderer-job.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/wanderer/client.ts src/core/acl-diff.ts src/jobs/wanderer.ts tests/acl-diff.test.ts tests/wanderer-client.test.ts tests/wanderer-job.test.ts +git commit -m "feat: wanderer ACL sync with post-mutation observation" +``` + +--- + +### Task 10: Discord REST client, role logic, and role sync job + +**Files:** +- Create: `src/lib/discord/rest.ts`, `src/core/role-diff.ts`, `src/jobs/discord-roles.ts` +- Test: `tests/discord-rest.test.ts`, `tests/role-diff.test.ts`, `tests/discord-roles-job.test.ts` + +**Interfaces:** +- Consumes: `Config["discord"]`, `runJob`, `postOpsWebhook`, `logAudit`, `discordLink`/`account` tables. +- Produces: + - `class DiscordApiError extends Error { status?: number; transient: boolean }` (429/5xx/network → transient). + - `createDiscordClient(cfg: Config, fetchImpl?: typeof fetch)` / `type DiscordClient` with: `getGuildRoles(): Promise>`, `getBotUserId(): Promise`, `getGuildMember(userId: string): Promise<{ roles: string[] } | null>` (404 → null), `addMemberRole(userId, roleId): Promise`, `removeMemberRole(userId, roleId): Promise`. Base `https://discord.com/api/v10`, `Authorization: Bot `. + - In `src/core/role-diff.ts`: + - `type ManagedRoleIds = { flygd: string; blue: string; green: string }` + - `diffRoles(input: { tier: "flygd" | "blue" | "green"; managed: ManagedRoleIds; memberRoleIds: string[] }): { add: string[]; remove: string[] }` — ensure exactly the tier's role among the three managed roles; other roles untouched. + - `stripManagedRoles(managed: ManagedRoleIds, memberRoleIds: string[]): string[]` — the managed roles the member currently has (for unlinked-user deprovision). + - `validateRoleConfig(input: { managed: ManagedRoleIds; guildRoles: Array<{ id: string; position: number; permissions: string }>; botRoleIds: string[] }): { ok: true } | { ok: false; error: string }` — three distinct ids, all present in the guild, bot has Manage Roles (or Administrator), bot's highest role above every managed role. + - `runDiscordRolesJob(deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, opts?: { accountId?: string; discordUserId?: string }): Promise` — job type `"discord-roles"`. Config validation runs FIRST each run; validation failure is **permanent-config**: posts the ops webhook immediately and returns `failed` WITHOUT retry. `opts.discordUserId` handles `{kind:"discord-user"}` outbox payloads: if the user is still unlinked, strip managed roles (not-in-guild → log and skip); if re-linked meanwhile, skip (the account path owns it). Otherwise iterate Discord-linked accounts (optionally scoped), ensuring exactly the tier's managed role; user-not-in-guild → count and skip; audits `discord.role_changed`. + +- [ ] **Step 1: Write failing tests** + +`tests/role-diff.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { diffRoles, stripManagedRoles, validateRoleConfig } from "@/core/role-diff"; + +const managed = { flygd: "10", blue: "11", green: "12" }; + +describe("diffRoles", () => { + it("adds the tier role and removes the other managed roles only", () => { + expect( + diffRoles({ tier: "flygd", managed, memberRoleIds: ["11", "12", "999"] }), + ).toEqual({ add: ["10"], remove: ["11", "12"] }); + }); + it("is a no-op when exactly the tier role is present", () => { + expect(diffRoles({ tier: "green", managed, memberRoleIds: ["12", "999"] })).toEqual({ + add: [], + remove: [], + }); + }); +}); + +describe("stripManagedRoles", () => { + it("returns only the managed roles the member has", () => { + expect(stripManagedRoles(managed, ["11", "999", "12"])).toEqual(["11", "12"]); + expect(stripManagedRoles(managed, ["999"])).toEqual([]); + }); +}); + +describe("validateRoleConfig", () => { + const MANAGE_ROLES = String(1 << 28); + const guildRoles = [ + { id: "10", position: 5, permissions: "0" }, + { id: "11", position: 4, permissions: "0" }, + { id: "12", position: 3, permissions: "0" }, + { id: "bot-role", position: 9, permissions: MANAGE_ROLES }, + ]; + + it("accepts a valid config", () => { + expect( + validateRoleConfig({ managed, guildRoles, botRoleIds: ["bot-role"] }), + ).toEqual({ ok: true }); + }); + it("rejects duplicate managed role ids", () => { + const r = validateRoleConfig({ + managed: { flygd: "10", blue: "10", green: "12" }, + guildRoles, + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false }); + }); + it("rejects managed roles missing from the guild", () => { + const r = validateRoleConfig({ + managed: { ...managed, blue: "404" }, + guildRoles, + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false, error: expect.stringContaining("404") }); + }); + it("rejects a bot without Manage Roles", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, permissions: "0" } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false, error: expect.stringContaining("Manage Roles") }); + }); + it("accepts Administrator in place of Manage Roles", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, permissions: String(1 << 3) } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toEqual({ ok: true }); + }); + it("rejects a bot whose highest role is not above the managed roles", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, position: 4 } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false }); + }); +}); +``` + +`tests/discord-rest.test.ts`: + +```ts +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDiscordClient, DiscordApiError } from "@/lib/discord/rest"; +import { testConfig } from "./helpers/config"; + +const cfg = testConfig(); // guild 9000, bot token "bot-token" +const API = "https://discord.com/api/v10"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("createDiscordClient", () => { + it("sends bot auth and parses guild roles", async () => { + server.use( + http.get(`${API}/guilds/9000/roles`, ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bot bot-token"); + return HttpResponse.json([ + { id: "10", name: "FlyGD", position: 5, permissions: "0", extra: "ignored" }, + ]); + }), + ); + expect(await createDiscordClient(cfg).getGuildRoles()).toEqual([ + { id: "10", name: "FlyGD", position: 5, permissions: "0" }, + ]); + }); + + it("returns null for a 404 guild member (user not in guild)", async () => { + server.use( + http.get(`${API}/guilds/9000/members/u1`, () => + HttpResponse.json({ message: "Unknown Member" }, { status: 404 }), + ), + ); + expect(await createDiscordClient(cfg).getGuildMember("u1")).toBeNull(); + }); + + it("classifies 429 as transient", async () => { + server.use( + http.get(`${API}/guilds/9000/members/u1`, () => + HttpResponse.json({}, { status: 429 }), + ), + ); + const err = await createDiscordClient(cfg).getGuildMember("u1").catch((e: unknown) => e); + expect(err).toBeInstanceOf(DiscordApiError); + expect((err as DiscordApiError).transient).toBe(true); + }); + + it("adds and removes member roles via PUT/DELETE", async () => { + const calls: string[] = []; + server.use( + http.put(`${API}/guilds/9000/members/u1/roles/10`, () => { + calls.push("put"); + return new HttpResponse(null, { status: 204 }); + }), + http.delete(`${API}/guilds/9000/members/u1/roles/11`, () => { + calls.push("delete"); + return new HttpResponse(null, { status: 204 }); + }), + ); + const d = createDiscordClient(cfg); + await d.addMemberRole("u1", "10"); + await d.removeMemberRole("u1", "11"); + expect(calls).toEqual(["put", "delete"]); + }); + + it("resolves the bot user id", async () => { + server.use( + http.get(`${API}/users/@me`, () => HttpResponse.json({ id: "bot-user" })), + ); + expect(await createDiscordClient(cfg).getBotUserId()).toBe("bot-user"); + }); +}); +``` + +`tests/discord-roles-job.test.ts`: + +```ts +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { auditLog, syncRun } from "@/db/schema"; +import { runDiscordRolesJob } from "@/jobs/discord-roles"; +import type { DiscordClient } from "@/lib/discord/rest"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); // managed roles 10/11/12 + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const MANAGE_ROLES = String(1 << 28); +const validGuildRoles = [ + { id: "10", name: "FlyGD", position: 5, permissions: "0" }, + { id: "11", name: "Blue", position: 4, permissions: "0" }, + { id: "12", name: "Green", position: 3, permissions: "0" }, + { id: "bot-role", name: "Bot", position: 9, permissions: MANAGE_ROLES }, +]; + +function fakeDiscord(members: Record, guildRoles = validGuildRoles) { + const added: Array<[string, string]> = []; + const removed: Array<[string, string]> = []; + const client: DiscordClient = { + getGuildRoles: async () => guildRoles, + getBotUserId: async () => "bot-user", + getGuildMember: async (userId) => { + if (userId === "bot-user") return { roles: ["bot-role"] }; + const roles = members[userId]; + return roles === null || roles === undefined ? null : { roles }; + }, + addMemberRole: async (userId, roleId) => { + added.push([userId, roleId]); + }, + removeMemberRole: async (userId, roleId) => { + removed.push([userId, roleId]); + }, + }; + return { client, added, removed }; +} + +describe("runDiscordRolesJob", () => { + it("ensures exactly the tier's managed role, leaving other roles alone", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u1" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const d = fakeDiscord({ u1: ["11", "999"] }); + const result = await runDiscordRolesJob({ db: ctx.db, cfg, discord: d.client }); + expect(result.status).toBe("ok"); + expect(d.added).toEqual([["u1", "10"]]); + expect(d.removed).toEqual([["u1", "11"]]); // 999 untouched + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "discord.role_changed")).toBe(true); + }); + + it("config validation failure is permanent: failed run, webhook, NO retry", async () => { + const badRoles = validGuildRoles.filter((r) => r.id !== "11"); // blue missing + const d = fakeDiscord({}, badRoles); + const webhook = vi.fn(async () => new Response("", { status: 204 })); + const result = await runDiscordRolesJob({ + db: ctx.db, + cfg, + discord: d.client, + fetchImpl: webhook as unknown as typeof fetch, + }); + expect(result.status).toBe("failed"); // returned, not thrown → no retry loop + expect(webhook).toHaveBeenCalledOnce(); + const runs = await ctx.db.select().from(syncRun); + expect(runs[0].status).toBe("failed"); + expect(runs[0].errorSummary).toContain("11"); + }); + + it("logs and skips users not in the guild", async () => { + const acc = await seedAccount(ctx.db, { tier: "green", discordUserId: "gone" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const d = fakeDiscord({ gone: null }); + const result = await runDiscordRolesJob({ db: ctx.db, cfg, discord: d.client }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ notInGuild: 1 }); + expect(d.added).toEqual([]); + }); + + it("strips managed roles from an unlinked discord user ({kind:'discord-user'})", async () => { + const d = fakeDiscord({ u9: ["10", "12", "999"] }); + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: d.client }, + { discordUserId: "u9" }, + ); + expect(result.status).toBe("ok"); + expect(d.removed.sort()).toEqual([ + ["u9", "10"], + ["u9", "12"], + ]); + expect(d.added).toEqual([]); + }); + + it("skips the strip when the user re-linked meanwhile", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u9" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const d = fakeDiscord({ u9: ["10"] }); + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: d.client }, + { discordUserId: "u9" }, + ); + expect(result.counts).toMatchObject({ skipped: 1 }); + expect(d.removed).toEqual([]); + }); + + it("scopes to one account when accountId is passed", async () => { + const a1 = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u1" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: a1.id, main: true }); + const a2 = await seedAccount(ctx.db, { tier: "green", discordUserId: "u2" }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: a2.id, main: true }); + const d = fakeDiscord({ u1: [], u2: [] }); + await runDiscordRolesJob( + { db: ctx.db, cfg, discord: d.client }, + { accountId: a1.id }, + ); + expect(d.added).toEqual([["u1", "10"]]); // u2 untouched + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm test -- tests/role-diff.test.ts tests/discord-rest.test.ts tests/discord-roles-job.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/core/role-diff.ts`: + +```ts +export type ManagedRoleIds = { flygd: string; blue: string; green: string }; + +/** Ensure exactly the tier's role among the three managed roles; all other roles untouched. */ +export function diffRoles(input: { + tier: "flygd" | "blue" | "green"; + managed: ManagedRoleIds; + memberRoleIds: string[]; +}): { add: string[]; remove: string[] } { + const want = input.managed[input.tier]; + const managedAll = [input.managed.flygd, input.managed.blue, input.managed.green]; + const have = new Set(input.memberRoleIds); + return { + add: have.has(want) ? [] : [want], + remove: managedAll.filter((r) => r !== want && have.has(r)), + }; +} + +/** The managed roles a member currently carries (unlinked-user deprovision). */ +export function stripManagedRoles( + managed: ManagedRoleIds, + memberRoleIds: string[], +): string[] { + const managedAll = new Set([managed.flygd, managed.blue, managed.green]); + return memberRoleIds.filter((r) => managedAll.has(r)); +} + +const MANAGE_ROLES = 1n << 28n; +const ADMINISTRATOR = 1n << 3n; + +/** + * Spec job 4 config validation: three distinct managed role ids that exist in + * the guild; bot has Manage Roles (or Administrator); bot's highest role sits + * ABOVE every managed role. Failure is permanent-config — no retry loop. + */ +export function validateRoleConfig(input: { + managed: ManagedRoleIds; + guildRoles: Array<{ id: string; position: number; permissions: string }>; + botRoleIds: string[]; +}): { ok: true } | { ok: false; error: string } { + const ids = [input.managed.flygd, input.managed.blue, input.managed.green]; + if (new Set(ids).size !== 3) { + return { ok: false, error: "managed role ids are not distinct" }; + } + const byId = new Map(input.guildRoles.map((r) => [r.id, r])); + const missing = ids.filter((id) => !byId.has(id)); + if (missing.length > 0) { + return { ok: false, error: `managed roles missing from guild: ${missing.join(", ")}` }; + } + const botRoles = input.botRoleIds.flatMap((id) => { + const role = byId.get(id); + return role ? [role] : []; + }); + const canManage = botRoles.some( + (r) => (BigInt(r.permissions) & (MANAGE_ROLES | ADMINISTRATOR)) !== 0n, + ); + if (!canManage) return { ok: false, error: "bot lacks Manage Roles" }; + const botTop = botRoles.reduce((max, r) => Math.max(max, r.position), -1); + const tooHigh = ids.filter((id) => { + const role = byId.get(id); + return role !== undefined && role.position >= botTop; + }); + if (tooHigh.length > 0) { + return { + ok: false, + error: `bot's highest role is not above managed roles: ${tooHigh.join(", ")}`, + }; + } + return { ok: true }; +} +``` + +`src/lib/discord/rest.ts`: + +```ts +import { z } from "zod"; +import type { Config } from "@/config"; + +const API = "https://discord.com/api/v10"; + +export class DiscordApiError extends Error { + status?: number; + transient: boolean; + constructor(message: string, opts: { status?: number; transient: boolean }) { + super(message); + this.status = opts.status; + this.transient = opts.transient; + } +} + +const roleSchema = z.object({ + id: z.string(), + name: z.string(), + position: z.number().int(), + permissions: z.string(), +}); +const memberSchema = z.object({ roles: z.array(z.string()) }); +const userSchema = z.object({ id: z.string() }); + +export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch) { + async function rawRequest(path: string, init: RequestInit = {}): Promise { + try { + return await fetchImpl(`${API}${path}`, { + ...init, + headers: { + authorization: `Bot ${cfg.discord.botToken}`, + "content-type": "application/json", + ...(init.headers as Record | undefined), + }, + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + throw new DiscordApiError( + `discord request failed: ${err instanceof Error ? err.message : String(err)}`, + { transient: true }, + ); + } + } + + function assertOk(res: Response, method: string, path: string): Response { + if (!res.ok) { + throw new DiscordApiError(`discord ${method} ${path} failed (${res.status})`, { + status: res.status, + transient: res.status === 429 || res.status >= 500, + }); + } + return res; + } + + async function request(path: string, init: RequestInit = {}): Promise { + return assertOk(await rawRequest(path, init), init.method ?? "GET", path); + } + + const guild = cfg.discord.guildId; + + return { + async getGuildRoles() { + const res = await request(`/guilds/${guild}/roles`); + return z.array(roleSchema).parse(await res.json()); + }, + async getBotUserId(): Promise { + const res = await request("/users/@me"); + return userSchema.parse(await res.json()).id; + }, + /** null when the user is not in the guild (404). */ + async getGuildMember(userId: string): Promise<{ roles: string[] } | null> { + const path = `/guilds/${guild}/members/${userId}`; + const res = await rawRequest(path); + if (res.status === 404) return null; + assertOk(res, "GET", path); + return memberSchema.parse(await res.json()); + }, + async addMemberRole(userId: string, roleId: string): Promise { + await request(`/guilds/${guild}/members/${userId}/roles/${roleId}`, { + method: "PUT", + }); + }, + async removeMemberRole(userId: string, roleId: string): Promise { + await request(`/guilds/${guild}/members/${userId}/roles/${roleId}`, { + method: "DELETE", + }); + }, + }; +} + +export type DiscordClient = ReturnType; +``` + +`src/jobs/discord-roles.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { account, discordLink } from "@/db/schema"; +import { diffRoles, stripManagedRoles, validateRoleConfig } from "@/core/role-diff"; +import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; +import { postOpsWebhook } from "@/lib/ops-webhook"; +import { logAudit } from "@/services/audit"; +import { runJob, type JobResult } from "@/services/sync-run"; + +export async function runDiscordRolesJob( + deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, + opts: { accountId?: string; discordUserId?: string } = {}, +): Promise { + const { db, cfg, discord } = deps; + return runJob(db, "discord-roles", async () => { + // Config validation FIRST, every run. A validation failure is + // permanent-config: alert immediately and do NOT retry-loop. (Transient + // fetch errors here throw → runJob records failed and pg-boss retries.) + const guildRoles = await discord.getGuildRoles(); + const botMember = await discord.getGuildMember(await discord.getBotUserId()); + const validation = botMember + ? validateRoleConfig({ + managed: cfg.discord.roleIds, + guildRoles, + botRoleIds: botMember.roles, + }) + : ({ ok: false, error: "bot is not a member of the configured guild" } as const); + if (!validation.ok) { + await postOpsWebhook( + cfg, + `authGD: discord role sync config invalid — ${validation.error}`, + deps.fetchImpl, + ); + return { status: "failed", errorSummary: validation.error }; + } + + // {kind:"discord-user"} deprovision payload: strip managed roles from a + // user who unlinked. If they re-linked meanwhile, the account path owns it. + if (opts.discordUserId) { + const links = await db + .select() + .from(discordLink) + .where(eq(discordLink.discordUserId, opts.discordUserId)); + if (links.length > 0) return { status: "ok", counts: { skipped: 1 } }; + const member = await discord.getGuildMember(opts.discordUserId); + if (!member) return { status: "ok", counts: { notInGuild: 1 } }; + const remove = stripManagedRoles(cfg.discord.roleIds, member.roles); + for (const roleId of remove) { + await discord.removeMemberRole(opts.discordUserId, roleId); + } + if (remove.length > 0) { + await logAudit(db, { + actor: "system", + action: "discord.role_changed", + target: opts.discordUserId, + details: { removed: remove, cause: "discord unlinked" }, + }); + } + return { status: "ok", counts: { removed: remove.length } }; + } + + const rows = await db + .select({ + accountId: account.id, + tier: account.tier, + discordUserId: discordLink.discordUserId, + }) + .from(discordLink) + .innerJoin(account, eq(discordLink.accountId, account.id)) + .where(opts.accountId ? eq(account.id, opts.accountId) : undefined); + + const counts = { changed: 0, notInGuild: 0, failed: 0 }; + let transientFailures = 0; + const errors: string[] = []; + for (const row of rows) { + try { + const member = await discord.getGuildMember(row.discordUserId); + if (!member) { + counts.notInGuild++; // user not in guild → log and skip + continue; + } + const diff = diffRoles({ + tier: row.tier, + managed: cfg.discord.roleIds, + memberRoleIds: member.roles, + }); + for (const roleId of diff.add) { + await discord.addMemberRole(row.discordUserId, roleId); + } + for (const roleId of diff.remove) { + await discord.removeMemberRole(row.discordUserId, roleId); + } + if (diff.add.length + diff.remove.length > 0) { + counts.changed++; + await logAudit(db, { + actor: "system", + action: "discord.role_changed", + target: row.discordUserId, + details: { added: diff.add, removed: diff.remove, tier: row.tier }, + }); + } + } catch (err) { + if (err instanceof DiscordApiError && !err.transient) counts.failed++; + else transientFailures++; + errors.push( + `${row.discordUserId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (transientFailures > 0 || counts.failed > 0) { + return { + status: "partial", + errorSummary: errors.slice(0, 5).join("; "), + counts, + retry: transientFailures > 0, + }; + } + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npm test -- tests/role-diff.test.ts tests/discord-rest.test.ts tests/discord-roles-job.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/discord/rest.ts src/core/role-diff.ts src/jobs/discord-roles.ts tests/role-diff.test.ts tests/discord-rest.test.ts tests/discord-roles-job.test.ts +git commit -m "feat: discord role sync with permanent-config validation" +``` + +--- + +### Task 11: Token health job + +**Files:** +- Create: `src/jobs/token-health.ts` +- Test: `tests/token-health-job.test.ts` + +**Interfaces:** +- Consumes: `getFreshAccessToken`, `verifyEveAccessToken`/`setTestJwksOverride` (`src/lib/esi/sso.ts`), `unlinkCharacter` (`src/services/accounts.ts`), `runJob`, `logAudit`. +- Produces: `runTokenHealthJob(deps: { db: Db; cfg: Config; fetchImpl?: typeof fetch }): Promise` — job type `"token-health"`. For every character with a stored token not already `invalid`: + - Refresh via `getFreshAccessToken` (permanent-only invalidation + rotation live there; transient → counted, retried at job level). + - Verify the returned access token JWT → current `ownerHash` + granted `scopes`. + - **owner_hash mismatch** → transfer: audit `character.owner_mismatch`, then `unlinkCharacter(tx, cfg, "system", id, { revokeSessions: true })` in one transaction (no-main rule + outbox fire inside the service). If the service refuses with `last_character`, fall back conservatively: mark the token `invalid` and keep the link — the new owner's first SSO login triggers the normal reclaim path. (Documented decision; the spec's unlink cannot orphan an account.) + - Otherwise persist current `scopes` and recompute `token_status`: full coverage of `cfg.eveSso.scopes` → `valid`, shortfall → `needs_reauth` (audit `token.needs_reauth` on transition). + - Transient refresh failures → `partial` + retry. Counts: `refreshed`, `invalid`, `needsReauth`, `unlinked`, `skipped`. + +- [ ] **Step 1: Write failing test** + +`tests/token-health-job.test.ts`: + +```ts +import { eq, sql } from "drizzle-orm"; +import { + SignJWT, + createLocalJWKSet, + exportJWK, + generateKeyPair, +} from "jose"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; +import { auditLog, character, session } from "@/db/schema"; +import { runTokenHealthJob } from "@/jobs/token-health"; +import { setTestJwksOverride } from "@/lib/esi/sso"; +import { JobRetryError } from "@/services/sync-run"; +import { createSession } from "@/services/session"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +let privateKey: CryptoKey; +beforeAll(async () => { + ctx = await setupTestDb(); + const pair = await generateKeyPair("RS256"); + privateKey = pair.privateKey; + setTestJwksOverride( + createLocalJWKSet({ keys: [{ ...(await exportJWK(pair.publicKey)), alg: "RS256" }] }), + ); +}); +afterAll(() => ctx.cleanup()); +afterAll(() => setTestJwksOverride(undefined)); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +async function signAccessToken(opts: { + characterId: number; + ownerHash: string; + scopes: string[]; +}): Promise { + return new SignJWT({ name: "Pilot", owner: opts.ownerHash, scp: opts.scopes }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("https://login.eveonline.com") + .setAudience("EVE Online") + .setSubject(`CHARACTER:EVE:${opts.characterId}`) + .setExpirationTime("5m") + .sign(privateKey); +} + +/** SSO token endpoint fake returning a signed access token per refresh. */ +function refreshFetchFor(accessTokens: Record): typeof fetch { + return (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = new URLSearchParams(init?.body as string); + const rt = body.get("refresh_token") ?? ""; + const at = accessTokens[rt]; + if (!at) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + } + return new Response( + JSON.stringify({ access_token: at, refresh_token: `${rt}-rotated` }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; +} + +async function getChar(id: number) { + const rows = await ctx.db.select().from(character).where(eq(character.id, id)); + return rows[0]; +} + +describe("runTokenHealthJob", () => { + it("keeps healthy tokens valid and rotates them", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-1", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ refreshed: 1 }); + expect((await getChar(1)).tokenStatus).toBe("valid"); + }); + + it("marks scope shortfalls needs_reauth (in-place re-auth, never unlink)", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-1", + scopes: ["esi-characters.read_contacts.v1"], // write scope missing + }); + await runTokenHealthJob({ db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }) }); + const ch = await getChar(1); + expect(ch.tokenStatus).toBe("needs_reauth"); + expect(ch.scopes).toEqual(["esi-characters.read_contacts.v1"]); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.needs_reauth")).toBe(true); + }); + + it("marks token invalid ONLY on permanent OAuth errors", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "revoked", ownerHash: "oh-1", + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({}), // every refresh → invalid_grant + }); + expect(result.counts).toMatchObject({ invalid: 1 }); + expect((await getChar(1)).tokenStatus).toBe("invalid"); + }); + + it("transient refresh failures change nothing and retry", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + const fetchImpl = (async () => + new Response(JSON.stringify({ error: "temporarily_unavailable" }), { + status: 503, + })) as typeof fetch; + await expect( + runTokenHealthJob({ db: ctx.db, cfg, fetchImpl }), + ).rejects.toBeInstanceOf(JobRetryError); + expect((await getChar(1)).tokenStatus).toBe("valid"); + }); + + it("owner_hash mismatch unlinks the character and revokes the account's sessions", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-old", + }); + await seedCharacter(ctx.db, cfg, { + id: 2, accountId: acc.id, refreshToken: null, tokenStatus: "missing", + }); + await createSession(ctx.db, acc.id); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-NEW", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.counts).toMatchObject({ unlinked: 1 }); + expect(await getChar(1)).toBeUndefined(); // unlinked + expect(await ctx.db.select().from(session)).toEqual([]); // sessions revoked + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "character.owner_mismatch")).toBe(true); + expect(audits.some((a) => a.action === "character.unlinked")).toBe(true); + }); + + it("falls back to invalid on an owner mismatch of the LAST character", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-old", + }); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-NEW", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.counts).toMatchObject({ unlinked: 0, invalid: 1 }); + const ch = await getChar(1); + expect(ch).toBeDefined(); // link kept — reclaim happens on the new owner's login + expect(ch.tokenStatus).toBe("invalid"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/token-health-job.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +`src/jobs/token-health.ts`: + +```ts +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character } from "@/db/schema"; +import { verifyEveAccessToken } from "@/lib/esi/sso"; +import { unlinkCharacter } from "@/services/accounts"; +import { logAudit } from "@/services/audit"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +export async function runTokenHealthJob(deps: { + db: Db; + cfg: Config; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg } = deps; + return runJob(db, "token-health", async () => { + const chars = await db.select().from(character); + const counts = { refreshed: 0, invalid: 0, needsReauth: 0, unlinked: 0, skipped: 0 }; + let transientFailures = 0; + + for (const ch of chars) { + if (!ch.refreshTokenEnc || ch.tokenStatus === "invalid") { + counts.skipped++; + continue; + } + const token = await getFreshAccessToken(db, cfg, ch, deps.fetchImpl); + if (!token.ok) { + if (token.reason === "transient") transientFailures++; + else counts.invalid++; // permanent-only invalidation done in the service + continue; + } + const identity = await verifyEveAccessToken(token.accessToken); + + if (identity.ownerHash !== ch.ownerHash) { + // Ownership transfer (spec: Auth flows). Unlink + revoke sessions; the + // account service applies the no-main rule and fires the outbox row. + const result = await db.transaction(async (tx) => { + await logAudit(tx, { + actor: "system", + action: "character.owner_mismatch", + target: String(ch.id), + details: { detectedBy: "token-health" }, + }); + return unlinkCharacter(tx, cfg, "system", ch.id, { revokeSessions: true }); + }); + if (result.ok) { + counts.unlinked++; + } else { + // last_character: unlinking would orphan the account. Conservative + // fallback — invalidate the token and keep the link; the new owner's + // first SSO login triggers the normal reclaim path. + await db + .update(character) + .set({ tokenStatus: "invalid" }) + .where(eq(character.id, ch.id)); + counts.invalid++; + } + continue; + } + + // Scope shortfall vs the CURRENT required set ⇒ needs_reauth (one-click + // in-place re-auth in the UI); full coverage ⇒ valid. + const covered = cfg.eveSso.scopes.every((s) => identity.scopes.includes(s)); + const nextStatus = covered ? ("valid" as const) : ("needs_reauth" as const); + await db + .update(character) + .set({ scopes: identity.scopes, tokenStatus: nextStatus }) + .where(eq(character.id, ch.id)); + if (nextStatus === "needs_reauth" && ch.tokenStatus !== "needs_reauth") { + await logAudit(db, { + actor: "system", + action: "token.needs_reauth", + target: String(ch.id), + }); + counts.needsReauth++; + } + counts.refreshed++; + } + + if (transientFailures > 0) { + return { + status: "partial", + errorSummary: `${transientFailures} transient refresh failures`, + counts, + retry: true, + }; + } + return { status: "ok", counts }; + }); +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `npm test -- tests/token-health-job.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/jobs/token-health.ts tests/token-health-job.test.ts +git commit -m "feat: daily token health job with transfer detection" +``` + +--- + +### Task 12: Purge job (carry-over) + +**Files:** +- Create: `src/jobs/purge.ts` +- Test: `tests/purge-job.test.ts` + +**Interfaces:** +- Consumes: `session`, `oauthTransaction`, `outbox` tables; `runJob`. +- Produces: `runPurgeJob(deps: { db: Db }): Promise` — job type `"purge"`. Deletes: expired `session` rows; consumed OR expired `oauth_transaction` rows; **dispatched** `outbox` rows older than 7 days (small scope addition beyond the carry-over so the outbox cannot grow unbounded; undispatched rows are never purged). Counts: `sessions`, `oauthTransactions`, `outbox`. + +- [ ] **Step 1: Write failing test** + +`tests/purge-job.test.ts`: + +```ts +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { oauthTransaction, outbox, session } from "@/db/schema"; +import { runPurgeJob } from "@/jobs/purge"; +import { setupTestDb } from "./helpers/db"; +import { seedAccount } from "./helpers/seed"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const DAY = 24 * 60 * 60 * 1000; + +describe("runPurgeJob", () => { + it("purges expired sessions, spent oauth transactions, and old dispatched outbox rows", async () => { + const acc = await seedAccount(ctx.db); + await ctx.db.insert(session).values([ + { id: "live", accountId: acc.id, expiresAt: new Date(Date.now() + DAY) }, + { id: "expired", accountId: acc.id, expiresAt: new Date(Date.now() - DAY) }, + ]); + await ctx.db.insert(oauthTransaction).values([ + { stateHash: "live", intent: "login", pkceVerifier: "v", expiresAt: new Date(Date.now() + DAY) }, + { stateHash: "expired", intent: "login", pkceVerifier: "v", expiresAt: new Date(Date.now() - DAY) }, + { stateHash: "consumed", intent: "login", pkceVerifier: "v", expiresAt: new Date(Date.now() + DAY), consumedAt: new Date() }, + ]); + await ctx.db.insert(outbox).values([ + { payload: { kind: "all" } }, // undispatched → NEVER purged + { payload: { kind: "all" }, dispatchedAt: new Date(), createdAt: new Date(Date.now() - 8 * DAY) }, + { payload: { kind: "all" }, dispatchedAt: new Date(), createdAt: new Date(Date.now() - DAY) }, + ]); + + const result = await runPurgeJob({ db: ctx.db }); + expect(result.status).toBe("ok"); + expect(result.counts).toEqual({ sessions: 1, oauthTransactions: 2, outbox: 1 }); + + expect((await ctx.db.select().from(session)).map((s) => s.id)).toEqual(["live"]); + expect((await ctx.db.select().from(oauthTransaction)).map((t) => t.stateHash)).toEqual(["live"]); + expect(await ctx.db.select().from(outbox)).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/purge-job.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +`src/jobs/purge.ts`: + +```ts +import { and, isNotNull, lt, or } from "drizzle-orm"; +import type { Db } from "@/db"; +import { oauthTransaction, outbox, session } from "@/db/schema"; +import { runJob, type JobResult } from "@/services/sync-run"; + +const OUTBOX_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +/** Carry-over hygiene: expired sessions, spent OAuth transactions, and old + * DISPATCHED outbox rows (undispatched rows are never purged). */ +export async function runPurgeJob(deps: { db: Db }): Promise { + const { db } = deps; + return runJob(db, "purge", async () => { + const now = new Date(); + const sessions = await db + .delete(session) + .where(lt(session.expiresAt, now)) + .returning({ id: session.id }); + const oauth = await db + .delete(oauthTransaction) + .where( + or( + isNotNull(oauthTransaction.consumedAt), + lt(oauthTransaction.expiresAt, now), + ), + ) + .returning({ id: oauthTransaction.id }); + const outboxRows = await db + .delete(outbox) + .where( + and( + isNotNull(outbox.dispatchedAt), + lt(outbox.createdAt, new Date(Date.now() - OUTBOX_RETENTION_MS)), + ), + ) + .returning({ id: outbox.id }); + return { + status: "ok", + counts: { + sessions: sessions.length, + oauthTransactions: oauth.length, + outbox: outboxRows.length, + }, + }; + }); +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `npm test -- tests/purge-job.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/jobs/purge.ts tests/purge-job.test.ts +git commit -m "feat: purge job for sessions, oauth transactions, and dispatched outbox rows" +``` + +--- + +### Task 13: Outbox dispatcher + +**Files:** +- Create: `src/worker/queues.ts` (queue-name constants only — Task 14 extends this file), `src/worker/dispatcher.ts` +- Test: `tests/dispatcher.test.ts` + +**Interfaces:** +- Consumes: `takeUndispatched`/`markDispatched`/`OutboxPayload` (`src/services/outbox.ts` — claim + mark MUST share one transaction). +- Produces: + - In `src/worker/queues.ts`: `const QUEUES = { membership: "membership", membershipRecheck: "membership-recheck", contacts: "contacts", wanderer: "wanderer", discordRoles: "discord-roles", tokenHealth: "token-health", purge: "purge", deadLetter: "ops-dead-letter" } as const` + - `type QueueSend = (queue: string, data: Record, options: { singletonKey: string }) => Promise` + - `planDispatch(payload: OutboxPayload): Array<{ queue: string; data: Record; singletonKey: string }>` — `{kind:"account"}` → account-scoped membership + discord-roles, global contacts + wanderer; `{kind:"discord-user"}` → discord-roles with `discordUserId`; `{kind:"all"}` → all four global. Every `data` includes `jobType` (dead-letter naming). + - `dispatchOutbox(db: Db, send: QueueSend): Promise` — claims rows and sends inside ONE transaction; a failed send rolls the claim back so rows retry next tick. + - `startDispatcher(db: Db, send: QueueSend, intervalMs?: number): () => void` — polling loop (default 2000 ms) with an overlap guard; returns a stop function. + +- [ ] **Step 1: Write failing test** + +`tests/dispatcher.test.ts`: + +```ts +import { isNull, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { outbox } from "@/db/schema"; +import { enqueueSync } from "@/services/outbox"; +import { dispatchOutbox, planDispatch } from "@/worker/dispatcher"; +import { setupTestDb } from "./helpers/db"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +type Sent = { queue: string; data: Record; singletonKey: string }; +const collector = () => { + const sent: Sent[] = []; + const send = async ( + queue: string, + data: Record, + options: { singletonKey: string }, + ) => { + sent.push({ queue, data, singletonKey: options.singletonKey }); + }; + return { sent, send }; +}; + +describe("planDispatch", () => { + it("fans an account payload out to scoped membership/roles and GLOBAL contacts/wanderer", () => { + const plan = planDispatch({ kind: "account", accountId: "acc-1" }); + expect(plan.map((p) => p.queue).sort()).toEqual([ + "contacts", + "discord-roles", + "membership", + "wanderer", + ]); + const membership = plan.find((p) => p.queue === "membership"); + expect(membership?.data).toMatchObject({ accountId: "acc-1", jobType: "membership" }); + expect(membership?.singletonKey).toBe("membership:acc-1"); + // desired sets are global — contacts/wanderer coalesce on fixed keys + expect(plan.find((p) => p.queue === "contacts")?.singletonKey).toBe("contacts:all"); + expect(plan.find((p) => p.queue === "wanderer")?.singletonKey).toBe("wanderer:all"); + }); + + it("maps discord-user payloads to a role strip job", () => { + expect(planDispatch({ kind: "discord-user", discordUserId: "u9" })).toEqual([ + { + queue: "discord-roles", + data: { jobType: "discord-roles", discordUserId: "u9" }, + singletonKey: "roles:user:u9", + }, + ]); + }); + + it("maps 'all' to the four sync queues", () => { + expect(planDispatch({ kind: "all" }).map((p) => p.queue).sort()).toEqual([ + "contacts", + "discord-roles", + "membership", + "wanderer", + ]); + }); +}); + +describe("dispatchOutbox", () => { + it("sends and marks rows dispatched in one pass; second pass is a no-op", async () => { + await enqueueSync(ctx.db, { kind: "account", accountId: "acc-1" }); + await enqueueSync(ctx.db, { kind: "discord-user", discordUserId: "u9" }); + const { sent, send } = collector(); + expect(await dispatchOutbox(ctx.db, send)).toBe(2); + expect(sent).toHaveLength(5); // 4 fan-out + 1 role strip + const undispatched = await ctx.db + .select() + .from(outbox) + .where(isNull(outbox.dispatchedAt)); + expect(undispatched).toEqual([]); + expect(await dispatchOutbox(ctx.db, send)).toBe(0); + expect(sent).toHaveLength(5); + }); + + it("rolls the claim back when a send fails, so rows retry next tick", async () => { + await enqueueSync(ctx.db, { kind: "all" }); + const failingSend = async () => { + throw new Error("pg-boss unavailable"); + }; + await expect(dispatchOutbox(ctx.db, failingSend)).rejects.toThrow("pg-boss unavailable"); + const undispatched = await ctx.db + .select() + .from(outbox) + .where(isNull(outbox.dispatchedAt)); + expect(undispatched).toHaveLength(1); // still claimable + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/dispatcher.test.ts` +Expected: FAIL (modules not found). + +- [ ] **Step 3: Implement** + +`src/worker/queues.ts` (Task 14 appends `createQueues`/`scheduleJobs` to this file): + +```ts +export const QUEUES = { + membership: "membership", + membershipRecheck: "membership-recheck", + contacts: "contacts", + wanderer: "wanderer", + discordRoles: "discord-roles", + tokenHealth: "token-health", + purge: "purge", + deadLetter: "ops-dead-letter", +} as const; +``` + +`src/worker/dispatcher.ts`: + +```ts +import type { Db } from "@/db"; +import { + markDispatched, + takeUndispatched, + type OutboxPayload, +} from "@/services/outbox"; +import { QUEUES } from "@/worker/queues"; + +export type QueueSend = ( + queue: string, + data: Record, + options: { singletonKey: string }, +) => Promise; + +/** + * Maps one outbox payload to its pg-boss sends. Membership and Discord roles + * are account-scopable; the desired contact/ACL sets are GLOBAL (every member + * pushes every other member), so account changes fan out to global + * reconciliations, coalesced by fixed singleton keys. Every payload carries + * jobType so the dead-letter handler can name the failed job. + */ +export function planDispatch( + payload: OutboxPayload, +): Array<{ queue: string; data: Record; singletonKey: string }> { + switch (payload.kind) { + case "account": + return [ + { + queue: QUEUES.membership, + data: { jobType: QUEUES.membership, accountId: payload.accountId }, + singletonKey: `membership:${payload.accountId}`, + }, + { + queue: QUEUES.contacts, + data: { jobType: QUEUES.contacts }, + singletonKey: "contacts:all", + }, + { + queue: QUEUES.wanderer, + data: { jobType: QUEUES.wanderer }, + singletonKey: "wanderer:all", + }, + { + queue: QUEUES.discordRoles, + data: { jobType: QUEUES.discordRoles, accountId: payload.accountId }, + singletonKey: `roles:${payload.accountId}`, + }, + ]; + case "discord-user": + return [ + { + queue: QUEUES.discordRoles, + data: { jobType: QUEUES.discordRoles, discordUserId: payload.discordUserId }, + singletonKey: `roles:user:${payload.discordUserId}`, + }, + ]; + case "all": + return [ + { + queue: QUEUES.membership, + data: { jobType: QUEUES.membership }, + singletonKey: "membership:all", + }, + { + queue: QUEUES.contacts, + data: { jobType: QUEUES.contacts }, + singletonKey: "contacts:all", + }, + { + queue: QUEUES.wanderer, + data: { jobType: QUEUES.wanderer }, + singletonKey: "wanderer:all", + }, + { + queue: QUEUES.discordRoles, + data: { jobType: QUEUES.discordRoles }, + singletonKey: "roles:all", + }, + ]; + } +} + +/** + * Claims undispatched rows and enqueues their jobs in ONE transaction (the + * takeUndispatched/markDispatched contract): a failed send rolls the claim + * back so rows are re-attempted next tick. FOR UPDATE SKIP LOCKED makes + * concurrent dispatchers safe without advisory locks. + */ +export async function dispatchOutbox(db: Db, send: QueueSend): Promise { + return db.transaction(async (tx) => { + const rows = await takeUndispatched(tx); + if (rows.length === 0) return 0; + for (const row of rows) { + for (const job of planDispatch(row.payload)) { + await send(job.queue, job.data, { singletonKey: job.singletonKey }); + } + } + await markDispatched( + tx, + rows.map((r) => r.id), + ); + return rows.length; + }); +} + +export function startDispatcher( + db: Db, + send: QueueSend, + intervalMs = 2000, +): () => void { + let running = false; + const timer = setInterval(() => { + if (running) return; + running = true; + void dispatchOutbox(db, send) + .catch((err) => console.error("outbox dispatch failed", err)) + .finally(() => { + running = false; + }); + }, intervalMs); + return () => clearInterval(timer); +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `npm test -- tests/dispatcher.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/worker/queues.ts src/worker/dispatcher.ts tests/dispatcher.test.ts +git commit -m "feat: transactional outbox dispatcher with singleton fan-out" +``` + +--- + +### Task 14: Worker entry — queues, schedules, workers, dead-letter alerts + +**Files:** +- Modify: `src/worker/queues.ts` (add `createQueues` + `scheduleJobs`), `package.json` (add `"worker": "tsx src/worker/index.ts"` to scripts) +- Create: `src/worker/index.ts` +- Test: `tests/worker-queues.test.ts` + +**Interfaces:** +- Consumes: everything from Tasks 6–13; `PgBoss` from `pg-boss`. +- Produces: + - `createQueues(boss: PgBoss): Promise` — creates the dead-letter queue plus all seven job queues with `{ retryLimit: 5, retryDelay: 60, retryBackoff: true, deadLetter: "ops-dead-letter" }`. + - `scheduleJobs(boss: PgBoss): Promise` — cron per spec: membership `*/30 * * * *`; membership-recheck (weekly `affiliation_invalid` recheck) `0 4 * * 0`; contacts `5 * * * *`; wanderer `10 * * * *`; discord-roles `15 * * * *`; token-health `0 3 * * *`; purge `30 3 * * *`. (pg-boss supports ONE schedule per queue — that's why the weekly recheck is its own queue.) + - `src/worker/index.ts` — the worker container entrypoint: starts pg-boss on `cfg.databaseUrl`, creates queues, registers `boss.work` handlers (zod-parsing job data), registers the dead-letter handler (posts ops webhook naming `data.jobType`), applies schedules, starts the dispatcher, and shuts down cleanly on SIGTERM/SIGINT. + +- [ ] **Step 1: Write failing test** + +`tests/worker-queues.test.ts` (integration against the test database — pg-boss owns its own `pgboss` schema there): + +```ts +import PgBoss from "pg-boss"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { QUEUES, createQueues, scheduleJobs } from "@/worker/queues"; + +const TEST_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://authgd:authgd@localhost:5433/authgd_test"; + +let boss: PgBoss; +beforeAll(async () => { + boss = new PgBoss({ connectionString: TEST_URL }); + boss.on("error", () => {}); + await boss.start(); + await createQueues(boss); +}); +afterAll(async () => { + await boss.stop({ graceful: false, wait: false }); +}); + +describe("worker queues", () => { + it("coalesces duplicate sends via singletonKey", async () => { + const key = `test-${Date.now()}`; // unique per run: pg-boss state persists + const first = await boss.send(QUEUES.contacts, { jobType: "contacts" }, { singletonKey: key }); + const second = await boss.send(QUEUES.contacts, { jobType: "contacts" }, { singletonKey: key }); + expect(first).not.toBeNull(); + expect(second).toBeNull(); // coalesced + }); + + it("applies one schedule per queue", async () => { + await scheduleJobs(boss); + const schedules = await boss.getSchedules(); + const byName = new Map(schedules.map((s) => [s.name, s.cron])); + expect(byName.get(QUEUES.membership)).toBe("*/30 * * * *"); + expect(byName.get(QUEUES.membershipRecheck)).toBe("0 4 * * 0"); + expect(byName.get(QUEUES.contacts)).toBe("5 * * * *"); + expect(byName.get(QUEUES.wanderer)).toBe("10 * * * *"); + expect(byName.get(QUEUES.discordRoles)).toBe("15 * * * *"); + expect(byName.get(QUEUES.tokenHealth)).toBe("0 3 * * *"); + expect(byName.get(QUEUES.purge)).toBe("30 3 * * *"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm test -- tests/worker-queues.test.ts` +Expected: FAIL (`createQueues` not exported). + +- [ ] **Step 3: Implement** + +Append to `src/worker/queues.ts`: + +```ts +import type PgBoss from "pg-boss"; + +/** ~5 tries over ~30 min: 60 s base delay with exponential backoff. */ +const RETRY = { retryLimit: 5, retryDelay: 60, retryBackoff: true }; + +const JOB_QUEUES = [ + QUEUES.membership, + QUEUES.membershipRecheck, + QUEUES.contacts, + QUEUES.wanderer, + QUEUES.discordRoles, + QUEUES.tokenHealth, + QUEUES.purge, +] as const; + +export async function createQueues(boss: PgBoss): Promise { + await boss.createQueue(QUEUES.deadLetter); + for (const name of JOB_QUEUES) { + // Final-retry failures dead-letter into ops-dead-letter → ops webhook. + await boss.createQueue(name, { name, ...RETRY, deadLetter: QUEUES.deadLetter }); + } +} + +/** + * Spec schedules. pg-boss allows ONE schedule per queue, which is why the + * weekly affiliation_invalid recheck is its own queue. Hourly jobs are + * staggered to avoid stampeding shared integrations. + */ +export async function scheduleJobs(boss: PgBoss): Promise { + await boss.schedule(QUEUES.membership, "*/30 * * * *", { jobType: QUEUES.membership }); + await boss.schedule(QUEUES.membershipRecheck, "0 4 * * 0", { + jobType: QUEUES.membershipRecheck, + }); + await boss.schedule(QUEUES.contacts, "5 * * * *", { jobType: QUEUES.contacts }); + await boss.schedule(QUEUES.wanderer, "10 * * * *", { jobType: QUEUES.wanderer }); + await boss.schedule(QUEUES.discordRoles, "15 * * * *", { jobType: QUEUES.discordRoles }); + await boss.schedule(QUEUES.tokenHealth, "0 3 * * *", { jobType: QUEUES.tokenHealth }); + await boss.schedule(QUEUES.purge, "30 3 * * *", { jobType: QUEUES.purge }); +} +``` + +(If the installed pg-boss v10 typings reject `name` inside the options object, drop that property — keep the retry + deadLetter options. Do not downgrade to positional/implicit queue creation.) + +`src/worker/index.ts`: + +```ts +import PgBoss from "pg-boss"; +import { z } from "zod"; +import { getConfig } from "@/config"; +import { createDb } from "@/db"; +import { runContactsJob } from "@/jobs/contacts"; +import { runDiscordRolesJob } from "@/jobs/discord-roles"; +import { runMembershipJob } from "@/jobs/membership"; +import { runPurgeJob } from "@/jobs/purge"; +import { runTokenHealthJob } from "@/jobs/token-health"; +import { runWandererJob } from "@/jobs/wanderer"; +import { createDiscordClient } from "@/lib/discord/rest"; +import { createEsiClient } from "@/lib/esi/client"; +import { postOpsWebhook } from "@/lib/ops-webhook"; +import { createWandererClient } from "@/lib/wanderer/client"; +import { startDispatcher } from "@/worker/dispatcher"; +import { QUEUES, createQueues, scheduleJobs } from "@/worker/queues"; + +const accountScopedSchema = z.object({ accountId: z.string().uuid().optional() }); +const discordJobSchema = z.object({ + accountId: z.string().uuid().optional(), + discordUserId: z.string().optional(), +}); +const deadLetterSchema = z.object({ jobType: z.string().optional() }).nullish(); + +async function main(): Promise { + const cfg = getConfig(); + const { db, pool } = createDb(cfg.databaseUrl); + const esi = createEsiClient(); + const wanderer = createWandererClient(cfg); + const discord = createDiscordClient(cfg); + + const boss = new PgBoss({ connectionString: cfg.databaseUrl }); + boss.on("error", (err) => console.error("pg-boss error", err)); + await boss.start(); + await createQueues(boss); + + // pg-boss v10 handlers receive an ARRAY of jobs. + await boss.work(QUEUES.membership, async ([job]) => { + const data = accountScopedSchema.parse(job.data); + await runMembershipJob({ db, cfg, esi }, { accountId: data.accountId }); + }); + await boss.work(QUEUES.membershipRecheck, async () => { + await runMembershipJob({ db, cfg, esi }, { recheckInvalid: true }); + }); + await boss.work(QUEUES.contacts, async () => { + await runContactsJob({ db, cfg, esi }); + }); + await boss.work(QUEUES.wanderer, async () => { + await runWandererJob({ db, wanderer }); + }); + await boss.work(QUEUES.discordRoles, async ([job]) => { + const data = discordJobSchema.parse(job.data); + await runDiscordRolesJob({ db, cfg, discord }, data); + }); + await boss.work(QUEUES.tokenHealth, async () => { + await runTokenHealthJob({ db, cfg }); + }); + await boss.work(QUEUES.purge, async () => { + await runPurgeJob({ db }); + }); + + // Ops alerting (spec: Error handling): a job landing here exhausted its + // retries — post to the optional Discord ops webhook. + await boss.work(QUEUES.deadLetter, async ([job]) => { + const data = deadLetterSchema.parse(job.data); + await postOpsWebhook( + cfg, + `authGD: job \`${data?.jobType ?? "unknown"}\` failed after final retry.`, + ); + }); + + await scheduleJobs(boss); + const stopDispatcher = startDispatcher(db, (queue, data, options) => + boss.send(queue, data, options), + ); + + const shutdown = async (): Promise => { + stopDispatcher(); + await boss.stop({ graceful: true, wait: true }); + await pool.end(); + process.exit(0); + }; + process.on("SIGTERM", () => void shutdown()); + process.on("SIGINT", () => void shutdown()); + console.log("authGD worker started"); +} + +main().catch((err) => { + console.error("worker failed to start", err); + process.exit(1); +}); +``` + +Add to `package.json` scripts (after `"start"`): + +```json + "worker": "tsx src/worker/index.ts", +``` + +- [ ] **Step 4: Run tests and typecheck** + +Run: `npm test -- tests/worker-queues.test.ts && npm run typecheck` +Expected: PASS, typecheck clean. (If pg-boss typings disagree on minor option shapes — e.g. `stop()` options or `work` handler generics — adapt the worker code to the installed typings; the behaviors in the Interfaces block are the contract, not the exact option spelling.) + +- [ ] **Step 5: Commit** + +```bash +git add src/worker/queues.ts src/worker/index.ts package.json tests/worker-queues.test.ts +git commit -m "feat: pg-boss worker entry with schedules and dead-letter ops alerts" +``` + +--- + +### Task 15: Full deprovision-path integration test and wrap-up verification + +The spec's required integration case: main leaves alliance → green → contact removals + ACL removals + role change + audit rows, driven through the real outbox dispatcher. + +**Files:** +- Test: `tests/deprovision-flow.test.ts` + +**Interfaces:** +- Consumes: `runMembershipJob`, `runContactsJob`, `runWandererJob`, `runDiscordRolesJob`, `dispatchOutbox`, seed helpers, fake clients (same shapes as Tasks 8–10 tests). + +- [ ] **Step 1: Write the integration test** + +`tests/deprovision-flow.test.ts`: + +```ts +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { auditLog, wandererAclObservation } from "@/db/schema"; +import { runContactsJob, type ContactsEsi } from "@/jobs/contacts"; +import { runDiscordRolesJob } from "@/jobs/discord-roles"; +import { runMembershipJob } from "@/jobs/membership"; +import { runWandererJob } from "@/jobs/wanderer"; +import type { DiscordClient } from "@/lib/discord/rest"; +import type { Affiliation } from "@/lib/esi/client"; +import type { WandererClient } from "@/lib/wanderer/client"; +import { dispatchOutbox } from "@/worker/dispatcher"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); +const LABEL_ID = 77; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const okToken = (async () => + new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt2" }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + +it("main leaves alliance → green → contacts removed, ACL removed, role changed, audited", async () => { + // leaver: flygd account with main (10) + alt (11), discord-linked + const leaver = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u-leaver" }); + await seedCharacter(ctx.db, cfg, { id: 10, accountId: leaver.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 11, accountId: leaver.id }); + // stayer: flygd account whose contacts currently include the leaver's chars + const stayer = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u-stayer" }); + await seedCharacter(ctx.db, cfg, { id: 20, accountId: stayer.id, main: true }); + + // 1) Membership: leaver's main left the alliance; stayer's main is still in. + const esiAffiliation = { + postAffiliation: async (ids: number[]): Promise => + ids.map((id) => ({ + characterId: id, + corporationId: 1, + allianceId: id === 20 ? 99000001 : null, + })), + }; + await runMembershipJob({ db: ctx.db, cfg, esi: esiAffiliation }); + + // 2) The demotion's outbox row fans out through the real dispatcher. + const sent: Array<{ queue: string }> = []; + const dispatched = await dispatchOutbox(ctx.db, async (queue) => { + sent.push({ queue }); + }); + expect(dispatched).toBeGreaterThanOrEqual(1); + expect(new Set(sent.map((s) => s.queue))).toEqual( + new Set(["membership", "contacts", "wanderer", "discord-roles"]), + ); + + // 3) Contact push: stayer's char 20 currently has 10 and 11 under our label. + const contactWrites = { deletes: [] as number[][], adds: [] as number[][] }; + const contactsEsi: ContactsEsi = { + getContactLabels: async () => [{ labelId: LABEL_ID, labelName: "flygd" }], + getAllContacts: async (characterId) => + characterId === 20 + ? [ + { contactId: 10, contactType: "character", standing: 5, labelIds: [LABEL_ID] }, + { contactId: 11, contactType: "character", standing: 5, labelIds: [LABEL_ID] }, + ] + : [], + addContacts: async (_c, _at, ids) => { + contactWrites.adds.push(ids); + }, + editContacts: async () => {}, + deleteContacts: async (_c, _at, ids) => { + contactWrites.deletes.push(ids); + }, + }; + await runContactsJob({ db: ctx.db, cfg, esi: contactsEsi, fetchImpl: okToken }); + // automatic removal (req. 3): the leaver's chars are deleted from 20's contacts + expect(contactWrites.deletes).toContainEqual([10, 11]); + + // 4) Wanderer: ACL still lists the leaver's chars → removed, observation fresh. + let aclMembers = [ + { characterId: 10, role: "member" }, + { characterId: 11, role: "member" }, + { characterId: 20, role: "member" }, + ]; + const wanderer: WandererClient = { + getAclMembers: async () => [...aclMembers], + addAclMember: async (id) => { + aclMembers.push({ characterId: id, role: "member" }); + }, + removeAclMember: async (id) => { + aclMembers = aclMembers.filter((m) => m.characterId !== id); + }, + }; + await runWandererJob({ db: ctx.db, wanderer }); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => o.characterId)).toEqual([20]); + + // 5) Discord roles: leaver ends with EXACTLY green; stayer keeps flygd. + const MANAGE_ROLES = String(1 << 28); + const roleOps = { added: [] as Array<[string, string]>, removed: [] as Array<[string, string]> }; + const memberRoles: Record = { + "u-leaver": ["10"], + "u-stayer": ["10"], + "bot-user": ["bot-role"], + }; + const discord: DiscordClient = { + getGuildRoles: async () => [ + { id: "10", name: "FlyGD", position: 5, permissions: "0" }, + { id: "11", name: "Blue", position: 4, permissions: "0" }, + { id: "12", name: "Green", position: 3, permissions: "0" }, + { id: "bot-role", name: "Bot", position: 9, permissions: MANAGE_ROLES }, + ], + getBotUserId: async () => "bot-user", + getGuildMember: async (userId) => + memberRoles[userId] ? { roles: memberRoles[userId] } : null, + addMemberRole: async (userId, roleId) => { + roleOps.added.push([userId, roleId]); + }, + removeMemberRole: async (userId, roleId) => { + roleOps.removed.push([userId, roleId]); + }, + }; + await runDiscordRolesJob({ db: ctx.db, cfg, discord }); + expect(roleOps.added).toContainEqual(["u-leaver", "12"]); + expect(roleOps.removed).toContainEqual(["u-leaver", "10"]); + expect(roleOps.added).not.toContainEqual(["u-stayer", "12"]); + + // 6) Audit trail: demotion cause + downstream actions all recorded. + const audits = await ctx.db.select().from(auditLog); + const tierChange = audits.find((a) => a.action === "tier.changed"); + expect(tierChange?.details).toMatchObject({ to: "green", cause: "main left alliance" }); + expect(audits.filter((a) => a.action === "wanderer.removed")).toHaveLength(2); + expect(audits.some((a) => a.action === "discord.role_changed")).toBe(true); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `npm test -- tests/deprovision-flow.test.ts` +Expected: PASS (everything it exercises was built in Tasks 6–13; failures here are integration bugs — fix them, do not weaken the test). + +- [ ] **Step 3: Full verification** + +Run: `npm test && npm run typecheck && npm run build` +Expected: all suites PASS (Plan 1's 76 tests plus everything added here), typecheck and production build clean. + +- [ ] **Step 4: Commit** + +```bash +git add tests/deprovision-flow.test.ts +git commit -m "test: full deprovision-path integration coverage" +``` + +--- + +## Not in this plan (Plan 3) + +- Admin UI: accounts page (tier/lock controls, cryo + notes, sort/filter, Map + last-login columns), audit log page, sync status page reading `sync_run` with "sync now" buttons (which enqueue `{kind:"all"}` / account-scoped outbox rows — the dispatcher built here already handles them). +- Admin route gating for `demoteAdmin` (+ `ORDER BY account.id` on its multi-row `FOR UPDATE` — carry-over). +- Dockerfile + deploy config (web + worker containers from one image; worker start command `npm run worker`), Playwright smoke tests. +- **Deploy-time verification of the assumed Wanderer API contract** (paths/shapes in `src/lib/wanderer/client.ts`). +- Login page `error` search param wiring (carry-over UI polish item). From 4600cc00e78634d91c3ecbbdfd1ce73c33d9e77a Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 20:51:42 -0400 Subject: [PATCH 02/28] docs: apply review findings to plan 2 (wanderer contract, transfer reclaim, rotation CAS, queue policy, X-Pages, subject binding, retry classification, worker-routing test) --- .../plans/2026-08-02-authgd-2-sync-engine.md | 760 +++++++++++++----- 1 file changed, 573 insertions(+), 187 deletions(-) diff --git a/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md b/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md index 2f85d31c..044db6b4 100644 --- a/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md +++ b/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md @@ -24,10 +24,10 @@ - There is no `onComplete`; "ops webhook after final retry failure" is implemented with a **dead-letter queue** (`ops-dead-letter`) that all job queues point at, plus an immediate webhook post for permanent-config failures. - Retry policy on every queue: `retryLimit: 5, retryDelay: 60, retryBackoff: true` (~5 tries over ~30 min). - Every `send`/`schedule` payload includes a `jobType` field so the dead-letter handler can name the failed job. - - Duplicate on-demand triggers are coalesced with `singletonKey` on `send`. + - Duplicate on-demand triggers are coalesced with `singletonKey` on `send` — **which requires `policy: "short"` on the queue**: pg-boss enforces singletonKey uniqueness only through the `job_i1` partial index scoped to `policy = 'short'` (created-state jobs, per `COALESCE(singleton_key,'')`); on the default `standard` policy a singletonKey coalesces nothing. `short` coalesces queued bursts while still permitting one trailing run when a trigger arrives during an active reconciliation. All job queues are created `short`. - **No new advisory locks.** Character locks own class 1 (`pg_advisory_xact_lock(1, hashint8(id))` in `src/services/accounts.ts`); the dispatcher needs none because `takeUndispatched` uses FOR UPDATE SKIP LOCKED — claim and `markDispatched` happen in the SAME transaction (contract documented in `src/services/outbox.ts`). - **Outbox fan-out semantics:** a `{kind:"account"}` row fans out to account-scoped membership + Discord-role jobs but **global** contacts/Wanderer jobs — adding a character to one account changes the desired set pushed to every other member. Global jobs coalesce via fixed singleton keys; ~20 accounts makes this cheap. The hourly schedules remain the backstop. -- **Wanderer API contract is assumed, not verified** (repo records only base URL/API key/ACL id). The assumed contract (`GET|POST /api/acls/{aclId}/members`, `DELETE /api/acls/{aclId}/members/{characterEveId}`, bearer auth, `eve_character_id` as digit-string) is isolated in `src/lib/wanderer/client.ts` and pinned by msw tests. **Verifying paths/shapes against the live instance is a deploy-time step (Plan 3).** +- **Wanderer API contract (confirmed 2026-08-02 from wanderer source):** members are read via `GET /api/acls/{aclId}` (bearer `apiKey`) under `data.members`; each member carries exactly ONE of `eve_character_id` / `eve_corporation_id` / `eve_alliance_id` (digit-strings) plus `role` (`admin|manager|member|viewer|blocked`). `POST /api/acls/{aclId}/members` with `{ member: { eve_character_id, role: "viewer" } }` adds (name is resolved server-side — never send it); `DELETE /api/acls/{aclId}/members/{eveId}` removes, where `{eveId}` is the EVE id, NOT the member row's UUID — a 404 means "already not a member" and is treated as idempotent success. **The sync job manages ONLY character entries; corporation/alliance members are never added, removed, or observed.** - All external clients accept an injectable `fetchImpl` (Plan 1 convention). Jobs declare client dependencies as `Pick` so tests inject fakes; HTTP behavior itself is tested at the client layer with msw. - Tests: table-driven unit tests for pure logic; integration tests against `TEST_DATABASE_URL` (dev compose Postgres) via `tests/helpers/db.ts`; msw for HTTP in client tests. `vitest` runs files serially (`fileParallelism: false`) so DB tests don't interfere. - The worker runs with `npm run worker` (tsx). Dockerfile/second-container start command is Plan 3 scope. @@ -500,7 +500,7 @@ git commit -m "feat: sync_run job wrapper and ops webhook" - `createEsiClient(opts?: { fetchImpl?: typeof fetch; now?: () => number; sleep?: (ms: number) => Promise; errorBudgetFloor?: number }): EsiClient` and `type EsiClient = ReturnType` with methods: - `postAffiliation(ids: number[]): Promise>` (public endpoint, ≤500 ids per call — throws if given more; chunking is the caller's job) - `getContactLabels(characterId: number, accessToken: string): Promise>` - - `getAllContacts(characterId: number, accessToken: string): Promise` — reads ALL pages (X-Pages header); ANY page failure rejects the whole call (partial reads are unsafe for destructive diffs) + - `getAllContacts(characterId: number, accessToken: string): Promise` — reads ALL pages; the `X-Pages` header is REQUIRED and must be a positive integer (missing/malformed/zero → reject: an unknown page count means an unknown contact set, and the downstream diff deletes); ANY page failure rejects the whole call (partial reads are unsafe for destructive diffs) - `addContacts(characterId, accessToken, contactIds: number[], standing: number, labelIds: number[]): Promise` (chunks of 100) - `editContacts(...same signature as addContacts): Promise` (PUT, chunks of 100) - `deleteContacts(characterId, accessToken, contactIds: number[]): Promise` (chunks of 20, query param) @@ -631,6 +631,28 @@ describe("contacts", () => { ]); }); + it("fails closed on a missing or malformed X-Pages header", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([]), // no X-Pages header at all + ), + ); + const esi = createEsiClient(); + await expect(esi.getAllContacts(90000001, "at")).rejects.toThrow(/X-Pages/); + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([], { headers: { "X-Pages": "abc" } }), + ), + ); + await expect(esi.getAllContacts(90000001, "at")).rejects.toThrow(/X-Pages/); + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([], { headers: { "X-Pages": "0" } }), + ), + ); + await expect(esi.getAllContacts(90000001, "at")).rejects.toThrow(/X-Pages/); + }); + it("rejects the whole read when any page fails", async () => { server.use( http.get(`${BASE}/characters/90000001/contacts/`, ({ request }) => { @@ -854,7 +876,17 @@ export function createEsiClient(opts: EsiClientOptions = {}) { const first = await request(`/characters/${characterId}/contacts/?page=1`, { accessToken, }); - const pages = Number(first.headers.get("x-pages") ?? "1"); + // Fail closed: an unknown page count means an unknown contact set, and the + // downstream diff deletes. Never guess (spec: never remove on unknown state). + const pagesHeader = first.headers.get("x-pages"); + const pages = Number(pagesHeader); + if (pagesHeader === null || !Number.isInteger(pages) || pages < 1) { + throw new EsiError( + `ESI GET contacts: missing or invalid X-Pages header (${pagesHeader})`, + 0, + "transient", + ); + } const raw = contactsSchema.parse(await first.json()).slice(); for (let page = 2; page <= pages; page++) { const res = await request( @@ -1191,6 +1223,7 @@ git commit -m "feat: affiliation chunk/bisect resolution and tier decision rule" - Produces: - `type CharacterTokenRow = { id: number; refreshTokenEnc: string | null; tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing" }` - `getFreshAccessToken(db: Db, cfg: Config, ch: CharacterTokenRow, fetchImpl?: typeof fetch): Promise` where `type AccessTokenResult = { ok: true; accessToken: string } | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }` — refreshes via `refreshEveToken`, persists the rotated refresh token on success. Permanent OAuth failures AND malformed stored blobs (carry-over: `decryptToken` throws uncleanly) mark `token_status: invalid` + audit `token.invalidated`; transient failures change no state. Used by Tasks 8 and 11. + - **Concurrency (EVE rotates refresh tokens on every use, and overlapping jobs may race):** the success path persists with compare-and-swap on the blob that was read (`WHERE refresh_token_enc = `); a lost race keeps the first writer's stored token and still returns `ok`. The permanent-failure path re-reads the row first: if the stored blob changed since our read, a concurrent job already rotated it — `invalid_grant` on the OLD token says nothing about the NEW one, so return `transient` and do NOT invalidate. - Test seed helpers in `tests/helpers/seed.ts`: `seedAccount(db, opts?)` and `seedCharacter(db, cfg, opts)` (exact signatures in code below). Later test tasks consume these. - [ ] **Step 1: Write the seed helper and failing test** @@ -1275,7 +1308,7 @@ export async function seedCharacter( import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { auditLog, character } from "@/db/schema"; -import { decryptToken } from "@/lib/crypto"; +import { decryptToken, encryptToken } from "@/lib/crypto"; import { getFreshAccessToken } from "@/services/tokens"; import { setupTestDb } from "./helpers/db"; import { testConfig } from "./helpers/config"; @@ -1365,6 +1398,37 @@ describe("getFreshAccessToken", () => { tokenJson({})) as typeof fetch); expect(r).toEqual({ ok: false, reason: "no_token" }); }); + + it("does not clobber a concurrently rotated token on success (CAS)", async () => { + const stale = await seed({ refreshToken: "old-rt" }); // row as WE read it + // another job rotates underneath us before our refresh completes + const currentBlob = encryptToken("current-rt", cfg.tokenEncryptionKey); + await ctx.db + .update(character) + .set({ refreshTokenEnc: currentBlob }) + .where(eq(character.id, 90000001)); + const fetchImpl = (async () => + tokenJson({ access_token: "our-at", refresh_token: "our-rt" })) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, stale, fetchImpl); + expect(r).toEqual({ ok: true, accessToken: "our-at" }); // our access token still works + const after = await getChar(90000001); + // …but the FIRST writer's stored refresh token wins + expect(decryptToken(after.refreshTokenEnc as string, cfg.tokenEncryptionKey)).toBe("current-rt"); + }); + + it("skips invalidation when the blob rotated during a failed refresh", async () => { + const stale = await seed({ refreshToken: "old-rt" }); + await ctx.db + .update(character) + .set({ refreshTokenEnc: encryptToken("current-rt", cfg.tokenEncryptionKey) }) + .where(eq(character.id, 90000001)); + // invalid_grant for the OLD token proves nothing about the NEW one + const fetchImpl = (async () => + tokenJson({ error: "invalid_grant" }, 400)) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, stale, fetchImpl); + expect(r).toMatchObject({ ok: false, reason: "transient" }); + expect((await getChar(90000001)).tokenStatus).toBe("valid"); // NOT invalidated + }); }); ``` @@ -1378,7 +1442,7 @@ Expected: FAIL (module not found). `src/services/tokens.ts`: ```ts -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { Config } from "@/config"; import type { Db } from "@/db"; import { character } from "@/db/schema"; @@ -1435,16 +1499,31 @@ export async function getFreshAccessToken( } try { const r = await refreshEveToken(cfg, refreshToken, fetchImpl); + // Compare-and-swap on the blob we read: EVE rotates refresh tokens on + // every use, so a concurrent job may have rotated first. If the CAS + // misses, keep the first writer's stored token — our access token is + // still valid for this run. await db .update(character) .set({ refreshTokenEnc: encryptToken(r.refreshToken, cfg.tokenEncryptionKey) }) - .where(eq(character.id, ch.id)); + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, ch.refreshTokenEnc)), + ); return { ok: true, accessToken: r.accessToken }; } catch (err) { if ( err instanceof EveSsoError && classifyOAuthError(err.oauthError, err.status) === "permanent" ) { + // invalid_grant on the OLD blob says nothing about a token another job + // rotated in the meantime — re-read before invalidating. + const [current] = await db + .select({ refreshTokenEnc: character.refreshTokenEnc }) + .from(character) + .where(eq(character.id, ch.id)); + if (!current || current.refreshTokenEnc !== ch.refreshTokenEnc) { + return { ok: false, reason: "transient", detail: "concurrent rotation" }; + } await markInvalid(db, ch.id, err.oauthError ?? `status_${err.status}`); return { ok: false, reason: "invalid", detail: err.oauthError }; } @@ -2066,7 +2145,7 @@ git commit -m "feat: desired-set query and label-scoped contacts diff" - `const CONTACT_SCOPES = ["esi-characters.read_contacts.v1", "esi-characters.write_contacts.v1"]` - `canPushContacts(ch: Pick): boolean` — per-job scope gate: token present, status not invalid/missing, and BOTH contact scopes granted. `needs_reauth` (missing some unrelated scope) is NOT a blocker. - `type ContactsEsi = Pick` - - `runContactsJob(deps: { db: Db; cfg: Config; esi: ContactsEsi; fetchImpl?: typeof fetch }): Promise` — job type `"contacts"`, global reconciliation over all push targets. Per character: labels first (missing configured label → record `missing_label`, skip ALL writes); read ALL contact pages before any destructive diff (any failure aborts that character); apply diff (updates grouped by preserved label set); record `contact_sync_state.last_result` (`ok` / `missing_label` / `token_invalid` / `token_refresh_failed` / `needs_reauth` / `sync_failed`), `last_synced_at` only on `ok`. A 403-scope `EsiError` (`kind === "needs_reauth"`) also sets the character's `token_status` to `needs_reauth`. Transient failures → `partial` + retry; per-character permanent failures → `partial` without retry; all clean → `ok`. + - `runContactsJob(deps: { db: Db; cfg: Config; esi: ContactsEsi; fetchImpl?: typeof fetch }): Promise` — job type `"contacts"`, global reconciliation over all push targets. Per character: labels first (missing configured label → record `missing_label`, skip ALL writes); read ALL contact pages before any destructive diff (any failure aborts that character); apply diff (updates grouped by preserved label set); record `contact_sync_state.last_result` for EVERY character in the desired set — including non-pushable ones (`token_invalid` for dead/absent tokens, `missing_scope` when the contact scopes aren't granted): `ok` / `missing_label` / `token_invalid` / `missing_scope` / `token_refresh_failed` / `needs_reauth` / `sync_failed`; `last_synced_at` only on `ok`. A 403-scope `EsiError` (`kind === "needs_reauth"`) also sets the character's `token_status` to `needs_reauth`. Transient failures → `partial` + retry; per-character permanent failures → `partial` without retry; all clean → `ok`. - [ ] **Step 1: Write failing test** @@ -2243,6 +2322,22 @@ describe("runContactsJob", () => { expect(calls.adds.filter((c) => c.characterId === 2)).toEqual([]); // …but 2 is still in 1's desired set expect(calls.adds).toContainEqual({ characterId: 1, ids: [2], labelIds: [LABEL_ID] }); + // and the skip reason is persisted for the UI + expect((await lastResult(2))?.lastResult).toBe("token_invalid"); + }); + + it("records missing_scope for targets lacking the contact scopes", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { + id: 2, + accountId: acc.id, + scopes: ["esi-characters.read_contacts.v1"], // write scope missing + tokenStatus: "needs_reauth", + }); + const { esi } = fakeEsi({}); + await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect((await lastResult(2))?.lastResult).toBe("missing_scope"); }); it("needs_reauth with contact scopes still syncs (per-job gating)", async () => { @@ -2356,6 +2451,17 @@ export async function runContactsJob(deps: { for (const target of flygd) { if (!canPushContacts(target)) { counts.skipped++; + // Persist WHY, so the member/admin pages can show remediation. + const deadToken = + !target.refreshTokenEnc || + target.tokenStatus === "invalid" || + target.tokenStatus === "missing"; + await recordResult( + db, + target.characterId, + deadToken ? "token_invalid" : "missing_scope", + false, + ); continue; } counts.targets++; @@ -2490,9 +2596,12 @@ git commit -m "feat: per-character contact push with label ownership and abort-o - Consumes: `Config["wanderer"]`, `getFlygdCharacters`, `runJob`, `wandererAclObservation` table, `logAudit`. - Produces: - `class WandererError extends Error { status?: number; transient: boolean }` (429/5xx/network → transient; other HTTP → permanent). - - `createWandererClient(cfg: Config, fetchImpl?: typeof fetch)` / `type WandererClient` with `getAclMembers(): Promise>`, `addAclMember(characterId: number): Promise`, `removeAclMember(characterId: number): Promise`. **Assumed contract** (verify against the live instance at deploy, Plan 3): `GET|POST {base}/api/acls/{aclId}/members`, `DELETE {base}/api/acls/{aclId}/members/{characterEveId}`, `Authorization: Bearer {apiKey}`, member shape `{ eve_character_id: "digits", role: string }` under `data`. - - `type AclMember = { characterId: number; role: string }`; `diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; remove: number[] }` — **`admin`-role entries are never removed; `manager` entries are removable like anyone else.** - - `runWandererJob(deps: { db: Db; wanderer: WandererClient }): Promise` — job type `"wanderer"`. Read fails → `failed` before ANY mutation (never remove on unknown state), retry per transience. After any mutation (or partial failure), **re-read the ACL and persist THAT read** wholesale into `wanderer_acl_observation`; when nothing was mutated, persist the initial read. If the post-mutation re-read fails, the observation is left untouched (stale-but-honest) and the run is `partial` + retry. Audits `wanderer.added` / `wanderer.removed` per successful mutation. + - `createWandererClient(cfg: Config, fetchImpl?: typeof fetch)` / `type WandererClient` — the confirmed contract (see Global Constraints): + - `getAclMembers(): Promise>` — `GET {base}/api/acls/{aclId}`, members under `data.members`. Members carrying `eve_corporation_id`/`eve_alliance_id` instead of `eve_character_id` are returned with `characterId: null` (NOT rejected as malformed). EVE ids accepted as digit-string or number. + - `addAclMember(characterId: number): Promise` — `POST {base}/api/acls/{aclId}/members` with `{ member: { eve_character_id: String(id), role: "viewer" } }`; the name is resolved server-side and never sent. + - `removeAclMember(characterId: number): Promise` — `DELETE {base}/api/acls/{aclId}/members/{characterId}` (the EVE id, not the member UUID); **404 = already not a member = idempotent success**. + - `type AclMember = { characterId: number; role: string }`; `diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; remove: number[] }` — **`admin`-role entries are never removed; `manager` entries are removable like anyone else.** Callers pass ONLY character entries. + - `runWandererJob(deps: { db: Db; wanderer: WandererClient }): Promise` — job type `"wanderer"`. **Corporation/alliance ACL entries (`characterId: null`) are filtered out before diffing — never added, removed, or observed.** Read fails → `failed` before ANY mutation (never remove on unknown state), retry per the error's transience. After any mutation (or partial failure), **re-read the ACL and persist THAT read's character entries** wholesale into `wanderer_acl_observation`; when nothing was mutated, persist the initial read. If the post-mutation re-read fails, the observation is left untouched (stale-but-honest). **Classification is preserved end-to-end:** `retry` is set only when at least one failure (mutation or re-read) was transient — all-permanent failures finish `partial` WITHOUT retry. Audits `wanderer.added` / `wanderer.removed` per successful mutation. - [ ] **Step 1: Write failing tests** @@ -2546,72 +2655,94 @@ import { createWandererClient, WandererError } from "@/lib/wanderer/client"; import { testConfig } from "./helpers/config"; const cfg = testConfig(); // base https://wanderer.example, aclId acl-1 -const MEMBERS = "https://wanderer.example/api/acls/acl-1/members"; +const ACL = "https://wanderer.example/api/acls/acl-1"; +const MEMBERS = `${ACL}/members`; const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); +const aclResponse = (members: unknown[]) => + HttpResponse.json({ + data: { id: "uuid", name: "My ACL", members }, + }); + describe("createWandererClient", () => { - it("reads ACL members with bearer auth and parses digit-string ids", async () => { + it("reads the ACL with bearer auth; corp/alliance members become characterId null", async () => { server.use( - http.get(MEMBERS, ({ request }) => { + http.get(ACL, ({ request }) => { expect(request.headers.get("authorization")).toBe("Bearer wkey"); - return HttpResponse.json({ - data: [ - { eve_character_id: "90000001", role: "admin" }, - { eve_character_id: "90000002", role: "member" }, - ], - }); + return aclResponse([ + { id: "m1", name: "Pilot A", eve_character_id: "90000001", role: "admin" }, + { id: "m2", name: "Pilot B", eve_character_id: "90000002", role: "viewer" }, + { id: "m3", name: "Some Corp", eve_corporation_id: "98000001", role: "viewer" }, + { id: "m4", name: "Some Alliance", eve_alliance_id: "99000009", role: "blocked" }, + ]); }), ); const w = createWandererClient(cfg); expect(await w.getAclMembers()).toEqual([ { characterId: 90000001, role: "admin" }, - { characterId: 90000002, role: "member" }, + { characterId: 90000002, role: "viewer" }, + { characterId: null, role: "viewer" }, + { characterId: null, role: "blocked" }, ]); }); it("fails closed on malformed member payloads", async () => { server.use( - http.get(MEMBERS, () => - HttpResponse.json({ data: [{ eve_character_id: "not-digits", role: "x" }] }), + http.get(ACL, () => + aclResponse([{ eve_character_id: "not-digits", role: "x" }]), ), ); await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); }); it("classifies 5xx as transient and 403 as permanent", async () => { - server.use(http.get(MEMBERS, () => HttpResponse.json({}, { status: 502 }))); + server.use(http.get(ACL, () => HttpResponse.json({}, { status: 502 }))); let err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); expect(err).toBeInstanceOf(WandererError); expect((err as WandererError).transient).toBe(true); - server.use(http.get(MEMBERS, () => HttpResponse.json({}, { status: 403 }))); + server.use(http.get(ACL, () => HttpResponse.json({}, { status: 403 }))); err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); expect((err as WandererError).transient).toBe(false); }); - it("adds and removes members on the assumed endpoints", async () => { + it("adds members as viewer without a name, and deletes by EVE id", async () => { const posts: unknown[] = []; let deleted = ""; server.use( http.post(MEMBERS, async ({ request }) => { posts.push(await request.json()); - return HttpResponse.json({}, { status: 201 }); + return HttpResponse.json({ + data: { id: "uuid", name: "Resolved Server-Side", role: "viewer", eve_character_id: "90000003" }, + }); }), http.delete(`${MEMBERS}/:id`, ({ params }) => { deleted = params.id as string; - return HttpResponse.json({}); + return HttpResponse.json({ ok: true }); }), ); const w = createWandererClient(cfg); await w.addAclMember(90000003); await w.removeAclMember(90000004); - expect(posts).toEqual([{ member: { eve_character_id: "90000003", role: "member" } }]); + expect(posts).toEqual([{ member: { eve_character_id: "90000003", role: "viewer" } }]); expect(deleted).toBe("90000004"); }); + + it("treats a 404 on delete as idempotent success", async () => { + server.use( + http.delete(`${MEMBERS}/:id`, () => + HttpResponse.json( + { error: "Membership not found for given ACL and external id" }, + { status: 404 }, + ), + ), + ); + await expect(createWandererClient(cfg).removeAclMember(90000005)).resolves.toBeUndefined(); + }); }); ``` @@ -2643,13 +2774,15 @@ beforeEach(async () => { `); }); -type Member = { characterId: number; role: string }; +type Member = { characterId: number | null; role: string }; /** Fake Wanderer with a mutable member list and scriptable failures. */ function fakeWanderer(initial: Member[], opts: { failFirstRead?: boolean; failReRead?: boolean; failRemoveOf?: number; + /** When set with failRemoveOf, the remove failure is permanent (transient: false). */ + permanentRemoveFailure?: boolean; } = {}) { let members = [...initial]; let reads = 0; @@ -2665,11 +2798,14 @@ function fakeWanderer(initial: Member[], opts: { return [...members]; }, addAclMember: async (id) => { - members.push({ characterId: id, role: "member" }); + members.push({ characterId: id, role: "viewer" }); }, removeAclMember: async (id) => { if (opts.failRemoveOf === id) { - throw new WandererError("remove failed", { status: 500, transient: true }); + throw new WandererError("remove failed", { + status: opts.permanentRemoveFailure ? 400 : 500, + transient: !opts.permanentRemoveFailure, + }); } members = members.filter((m) => m.characterId !== id); }, @@ -2689,14 +2825,18 @@ describe("runWandererJob", () => { { characterId: 2, role: "member" }, { characterId: 3, role: "admin" }, { characterId: 4, role: "manager" }, + { characterId: null, role: "viewer" }, // corp/alliance entry — never touched ]); const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); expect(result.status).toBe("ok"); expect(result.counts).toMatchObject({ added: 1, removed: 2 }); expect(w.reads()).toBe(2); // initial + post-mutation + // corp/alliance entry survived untouched… + expect(w.members().some((m) => m.characterId === null)).toBe(true); + // …and the observation holds only character entries const observed = await ctx.db.select().from(wandererAclObservation); expect(observed.map((o) => [o.characterId, o.role]).sort()).toEqual([ - [1, "member"], + [1, "viewer"], [3, "admin"], ]); const audits = await ctx.db.select().from(auditLog); @@ -2742,6 +2882,21 @@ describe("runWandererJob", () => { expect(observed.map((o) => o.characterId).sort((a, b) => a - b)).toEqual([1, 5]); }); + it("does NOT retry when every failure was permanent", async () => { + await seedFlygdChar(1); + const w = fakeWanderer( + [ + { characterId: 1, role: "viewer" }, + { characterId: 5, role: "member" }, + ], + { failRemoveOf: 5, permanentRemoveFailure: true }, + ); + // returned, not thrown: permanent failures must not retry-loop + const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(result.status).toBe("partial"); + expect(result.retry).toBeUndefined(); + }); + it("leaves the previous observation untouched when the re-read fails", async () => { await seedFlygdChar(1); await ctx.db.insert(wandererAclObservation).values({ @@ -2796,9 +2951,14 @@ export function diffAcl(input: { desiredIds: number[]; members: AclMember[] }): import { z } from "zod"; import type { Config } from "@/config"; -// ASSUMED CONTRACT — the repo records only base URL / API key / ACL id, not -// the REST shapes. These paths and payloads are pinned by the msw tests and -// MUST be verified against the live Wanderer instance at deploy time (Plan 3). +// Wanderer ACL API — contract confirmed 2026-08-02 against wanderer source +// (access_list_api_controller.ex / access_list_member_api_controller.ex): +// GET /api/acls/:aclId → { data: { ..., members: [...] } } +// POST /api/acls/:aclId/members → { data: {...member} } (name resolved server-side) +// DELETE /api/acls/:aclId/members/:id → { ok: true }; 404 = not a member (idempotent) +// :id is the EVE character/corp/alliance id, NOT the member row's UUID. Each +// member carries exactly one of eve_character_id / eve_corporation_id / +// eve_alliance_id; non-character members surface here as characterId: null. export class WandererError extends Error { status?: number; @@ -2810,25 +2970,30 @@ export class WandererError extends Error { } } -const membersSchema = z.object({ - data: z.array( - z.object({ - eve_character_id: z.string().regex(/^\d+$/), - role: z.string(), - }), - ), +const eveIdSchema = z.union([z.string().regex(/^\d+$/), z.number().int()]); +const aclSchema = z.object({ + data: z.object({ + members: z.array( + z.object({ + role: z.string(), + eve_character_id: eveIdSchema.nullish(), + eve_corporation_id: eveIdSchema.nullish(), + eve_alliance_id: eveIdSchema.nullish(), + }), + ), + }), }); -export type WandererAclMember = { characterId: number; role: string }; +export type WandererAclMember = { characterId: number | null; role: string }; export function createWandererClient(cfg: Config, fetchImpl: typeof fetch = fetch) { const base = cfg.wanderer.baseUrl.replace(/\/$/, ""); - const membersPath = `/api/acls/${cfg.wanderer.aclId}/members`; + const aclPath = `/api/acls/${cfg.wanderer.aclId}`; + const membersPath = `${aclPath}/members`; - async function request(path: string, init: RequestInit = {}): Promise { - let res: Response; + async function rawRequest(path: string, init: RequestInit = {}): Promise { try { - res = await fetchImpl(`${base}${path}`, { + return await fetchImpl(`${base}${path}`, { ...init, headers: { authorization: `Bearer ${cfg.wanderer.apiKey}`, @@ -2843,32 +3008,44 @@ export function createWandererClient(cfg: Config, fetchImpl: typeof fetch = fetc { transient: true }, ); } + } + + function assertOk(res: Response, method: string, path: string): Response { if (!res.ok) { - throw new WandererError( - `wanderer ${init.method ?? "GET"} ${path} failed (${res.status})`, - { status: res.status, transient: res.status === 429 || res.status >= 500 }, - ); + throw new WandererError(`wanderer ${method} ${path} failed (${res.status})`, { + status: res.status, + transient: res.status === 429 || res.status >= 500, + }); } return res; } + async function request(path: string, init: RequestInit = {}): Promise { + return assertOk(await rawRequest(path, init), init.method ?? "GET", path); + } + return { async getAclMembers(): Promise { - const res = await request(membersPath); - return membersSchema - .parse(await res.json()) - .data.map((m) => ({ characterId: Number(m.eve_character_id), role: m.role })); + const res = await request(aclPath); + return aclSchema.parse(await res.json()).data.members.map((m) => ({ + characterId: m.eve_character_id != null ? Number(m.eve_character_id) : null, + role: m.role, + })); }, async addAclMember(characterId: number): Promise { + // role "viewer" (wanderer's default); name is resolved server-side. await request(membersPath, { method: "POST", body: JSON.stringify({ - member: { eve_character_id: String(characterId), role: "member" }, + member: { eve_character_id: String(characterId), role: "viewer" }, }), }); }, async removeAclMember(characterId: number): Promise { - await request(`${membersPath}/${characterId}`, { method: "DELETE" }); + const path = `${membersPath}/${characterId}`; + const res = await rawRequest(path, { method: "DELETE" }); + if (res.status === 404) return; // already not a member — idempotent + assertOk(res, "DELETE", path); }, }; } @@ -2887,6 +3064,20 @@ import { logAudit } from "@/services/audit"; import { getFlygdCharacters } from "@/services/desired"; import { runJob, type JobResult } from "@/services/sync-run"; +type CharacterEntry = { characterId: number; role: string }; + +/** The job manages ONLY character entries; corp/alliance members are inert. */ +function characterEntries( + members: Array<{ characterId: number | null; role: string }>, +): CharacterEntry[] { + return members.flatMap((m) => + m.characterId !== null ? [{ characterId: m.characterId, role: m.role }] : [], + ); +} + +const isTransient = (err: unknown): boolean => + err instanceof WandererError ? err.transient : true; + export async function runWandererJob(deps: { db: Db; wanderer: WandererClient; @@ -2903,12 +3094,13 @@ export async function runWandererJob(deps: { return { status: "failed", errorSummary: `ACL read failed: ${err instanceof Error ? err.message : String(err)}`, - retry: err instanceof WandererError ? err.transient : true, + ...(isTransient(err) ? { retry: true } : {}), }; } - const diff = diffAcl({ desiredIds, members }); + const diff = diffAcl({ desiredIds, members: characterEntries(members) }); const errors: string[] = []; + let anyTransient = false; let added = 0; let removed = 0; for (const id of diff.add) { @@ -2917,6 +3109,7 @@ export async function runWandererJob(deps: { added++; await logAudit(db, { actor: "system", action: "wanderer.added", target: String(id) }); } catch (err) { + anyTransient ||= isTransient(err); errors.push(`add ${id}: ${err instanceof Error ? err.message : String(err)}`); } } @@ -2926,6 +3119,7 @@ export async function runWandererJob(deps: { removed++; await logAudit(db, { actor: "system", action: "wanderer.removed", target: String(id) }); } catch (err) { + anyTransient ||= isTransient(err); errors.push(`remove ${id}: ${err instanceof Error ? err.message : String(err)}`); } } @@ -2936,12 +3130,13 @@ export async function runWandererJob(deps: { if (added + removed > 0 || errors.length > 0) { try { observed = await wanderer.getAclMembers(); - } catch { + } catch (err) { observed = null; // keep the previous observation: stale but honest + anyTransient ||= isTransient(err); } } if (observed !== null) { - const rows = observed; + const rows = characterEntries(observed); const observedAt = new Date(); await db.transaction(async (tx) => { await tx.delete(wandererAclObservation); @@ -2961,7 +3156,8 @@ export async function runWandererJob(deps: { .slice(0, 5) .join("; "), counts, - retry: true, + // Preserve classification: only transient trouble earns a retry. + ...(anyTransient ? { retry: true } : {}), }; } return { status: "ok", counts }; @@ -2999,7 +3195,7 @@ git commit -m "feat: wanderer ACL sync with post-mutation observation" - `diffRoles(input: { tier: "flygd" | "blue" | "green"; managed: ManagedRoleIds; memberRoleIds: string[] }): { add: string[]; remove: string[] }` — ensure exactly the tier's role among the three managed roles; other roles untouched. - `stripManagedRoles(managed: ManagedRoleIds, memberRoleIds: string[]): string[]` — the managed roles the member currently has (for unlinked-user deprovision). - `validateRoleConfig(input: { managed: ManagedRoleIds; guildRoles: Array<{ id: string; position: number; permissions: string }>; botRoleIds: string[] }): { ok: true } | { ok: false; error: string }` — three distinct ids, all present in the guild, bot has Manage Roles (or Administrator), bot's highest role above every managed role. - - `runDiscordRolesJob(deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, opts?: { accountId?: string; discordUserId?: string }): Promise` — job type `"discord-roles"`. Config validation runs FIRST each run; validation failure is **permanent-config**: posts the ops webhook immediately and returns `failed` WITHOUT retry. `opts.discordUserId` handles `{kind:"discord-user"}` outbox payloads: if the user is still unlinked, strip managed roles (not-in-guild → log and skip); if re-linked meanwhile, skip (the account path owns it). Otherwise iterate Discord-linked accounts (optionally scoped), ensuring exactly the tier's managed role; user-not-in-guild → count and skip; audits `discord.role_changed`. + - `runDiscordRolesJob(deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, opts?: { accountId?: string; discordUserId?: string }): Promise` — job type `"discord-roles"`. Config validation runs FIRST each run; validation failure is **permanent-config**: posts the ops webhook immediately and returns `failed` WITHOUT retry. **A permanent `DiscordApiError` (e.g. 401/403 — bad bot token, missing access) while FETCHING the config data takes the same permanent-config path**; only transient fetch errors propagate into a pg-boss retry. `opts.discordUserId` handles `{kind:"discord-user"}` outbox payloads: if the user is still unlinked, strip managed roles (not-in-guild → log and skip); if re-linked meanwhile, skip (the account path owns it). Otherwise iterate Discord-linked accounts (optionally scoped), ensuring exactly the tier's managed role; user-not-in-guild → count and skip; audits `discord.role_changed`. - [ ] **Step 1: Write failing tests** @@ -3181,7 +3377,7 @@ import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { auditLog, syncRun } from "@/db/schema"; import { runDiscordRolesJob } from "@/jobs/discord-roles"; -import type { DiscordClient } from "@/lib/discord/rest"; +import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; import { setupTestDb } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -3260,6 +3456,45 @@ describe("runDiscordRolesJob", () => { expect(runs[0].errorSummary).toContain("11"); }); + it("treats a permanent config-fetch error (403) as permanent-config: no retry", async () => { + const d = fakeDiscord({}); + const client: DiscordClient = { + ...d.client, + getGuildRoles: async () => { + throw new DiscordApiError("discord GET /guilds/9000/roles failed (403)", { + status: 403, + transient: false, + }); + }, + }; + const webhook = vi.fn(async () => new Response("", { status: 204 })); + // returned, not thrown: a bad bot token must not retry-loop + const result = await runDiscordRolesJob({ + db: ctx.db, + cfg, + discord: client, + fetchImpl: webhook as unknown as typeof fetch, + }); + expect(result.status).toBe("failed"); + expect(webhook).toHaveBeenCalledOnce(); + }); + + it("still retries transient config-fetch errors", async () => { + const d = fakeDiscord({}); + const client: DiscordClient = { + ...d.client, + getGuildRoles: async () => { + throw new DiscordApiError("discord GET /guilds/9000/roles failed (503)", { + status: 503, + transient: true, + }); + }, + }; + await expect( + runDiscordRolesJob({ db: ctx.db, cfg, discord: client }), + ).rejects.toThrow(/503/); // thrown → pg-boss retries + }); + it("logs and skips users not in the guild", async () => { const acc = await seedAccount(ctx.db, { tier: "green", discordUserId: "gone" }); await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); @@ -3508,10 +3743,22 @@ export async function runDiscordRolesJob( const { db, cfg, discord } = deps; return runJob(db, "discord-roles", async () => { // Config validation FIRST, every run. A validation failure is - // permanent-config: alert immediately and do NOT retry-loop. (Transient - // fetch errors here throw → runJob records failed and pg-boss retries.) - const guildRoles = await discord.getGuildRoles(); - const botMember = await discord.getGuildMember(await discord.getBotUserId()); + // permanent-config: alert immediately and do NOT retry-loop. The same goes + // for PERMANENT errors fetching the config data (401/403 = bad bot token + // or missing access); only transient fetch errors throw → pg-boss retries. + let guildRoles; + let botMember; + try { + guildRoles = await discord.getGuildRoles(); + botMember = await discord.getGuildMember(await discord.getBotUserId()); + } catch (err) { + if (err instanceof DiscordApiError && !err.transient) { + const msg = `discord config check failed: ${err.message}`; + await postOpsWebhook(cfg, `authGD: ${msg}`, deps.fetchImpl); + return { status: "failed", errorSummary: msg }; + } + throw err; + } const validation = botMember ? validateRoleConfig({ managed: cfg.discord.roleIds, @@ -3629,20 +3876,24 @@ git commit -m "feat: discord role sync with permanent-config validation" --- -### Task 11: Token health job +### Task 11: Token health job (+ transfer-reclaim service export) **Files:** - Create: `src/jobs/token-health.ts` +- Modify: `src/services/accounts.ts` (export a transfer-specific reclaim operation) - Test: `tests/token-health-job.test.ts` **Interfaces:** -- Consumes: `getFreshAccessToken`, `verifyEveAccessToken`/`setTestJwksOverride` (`src/lib/esi/sso.ts`), `unlinkCharacter` (`src/services/accounts.ts`), `runJob`, `logAudit`. -- Produces: `runTokenHealthJob(deps: { db: Db; cfg: Config; fetchImpl?: typeof fetch }): Promise` — job type `"token-health"`. For every character with a stored token not already `invalid`: - - Refresh via `getFreshAccessToken` (permanent-only invalidation + rotation live there; transient → counted, retried at job level). - - Verify the returned access token JWT → current `ownerHash` + granted `scopes`. - - **owner_hash mismatch** → transfer: audit `character.owner_mismatch`, then `unlinkCharacter(tx, cfg, "system", id, { revokeSessions: true })` in one transaction (no-main rule + outbox fire inside the service). If the service refuses with `last_character`, fall back conservatively: mark the token `invalid` and keep the link — the new owner's first SSO login triggers the normal reclaim path. (Documented decision; the spec's unlink cannot orphan an account.) - - Otherwise persist current `scopes` and recompute `token_status`: full coverage of `cfg.eveSso.scopes` → `valid`, shortfall → `needs_reauth` (audit `token.needs_reauth` on transition). - - Transient refresh failures → `partial` + retry. Counts: `refreshed`, `invalid`, `needsReauth`, `unlinked`, `skipped`. +- Consumes: `getFreshAccessToken`, `verifyEveAccessToken`/`setTestJwksOverride` (`src/lib/esi/sso.ts`), `runJob`, `logAudit`, and the internal `reclaimCharacter`/`findCharacterForUpdate` helpers in `src/services/accounts.ts`. +- Produces: + - `reclaimTransferredCharacter(dbx: DbTx, characterId: number): Promise<{ ok: true } | { ok: false; error: "not_found" }>` exported from `src/services/accounts.ts` — transfer reclaim for background detection. **Unlike `unlinkCharacter` there is no last-character guard**: that guard exists only for ordinary unlink flows; transfer reclaim already legitimately produces zero-character accounts (spec: the account "simply stays Green until an admin deletes it"). Wraps the existing internal `reclaimCharacter` (advisory lock + row lock, delete link + contact state, audit `character.reclaimed`, no-main rule with demotion + outbox enqueue, session revocation). + - `runTokenHealthJob(deps: { db: Db; cfg: Config; fetchImpl?: typeof fetch }): Promise` — job type `"token-health"`. For every character with a stored token not already `invalid`: + - Refresh via `getFreshAccessToken` (permanent-only invalidation, CAS rotation, concurrent-rotation safety live there; transient → counted, retried at job level). + - Verify the returned access token JWT → subject character id, `ownerHash`, granted `scopes`. + - **Subject binding (fail closed):** if the JWT's character id ≠ the row's id, the token must never vouch for this row — mark `token_status: invalid` + audit `token.subject_mismatch`, keep the link, continue. + - **owner_hash mismatch** → ownership transfer: in ONE transaction, audit `character.owner_mismatch` then `reclaimTransferredCharacter(tx, ch.id)` — this deprovisions fully (main cleared, demotion unless locked, outbox row for jobs 2–4, sessions revoked) even when it is the account's last character. + - Otherwise persist current `scopes` and recompute `token_status`: full coverage of `cfg.eveSso.scopes` → `valid`, shortfall → `needs_reauth` (audit `token.needs_reauth` on transition). + - Transient refresh failures → `partial` + retry. Counts: `refreshed`, `invalid`, `needsReauth`, `unlinked`, `skipped`. - [ ] **Step 1: Write failing test** @@ -3664,7 +3915,7 @@ import { expect, it, } from "vitest"; -import { auditLog, character, session } from "@/db/schema"; +import { account, auditLog, character, outbox, session } from "@/db/schema"; import { runTokenHealthJob } from "@/jobs/token-health"; import { setTestJwksOverride } from "@/lib/esi/sso"; import { JobRetryError } from "@/services/sync-run"; @@ -3791,7 +4042,7 @@ describe("runTokenHealthJob", () => { expect((await getChar(1)).tokenStatus).toBe("valid"); }); - it("owner_hash mismatch unlinks the character and revokes the account's sessions", async () => { + it("owner_hash mismatch reclaims the character and revokes the account's sessions", async () => { const acc = await seedAccount(ctx.db, { tier: "flygd" }); await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-old", @@ -3807,28 +4058,61 @@ describe("runTokenHealthJob", () => { db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), }); expect(result.counts).toMatchObject({ unlinked: 1 }); - expect(await getChar(1)).toBeUndefined(); // unlinked + expect(await getChar(1)).toBeUndefined(); // reclaimed expect(await ctx.db.select().from(session)).toEqual([]); // sessions revoked + // no-main rule applied: main cleared, demoted, deprovision enqueued + const [after] = await ctx.db.select().from(account); + expect(after.mainCharacterId).toBeNull(); + expect(after.tier).toBe("green"); + const outboxRows = await ctx.db.select().from(outbox); + expect(outboxRows.map((r) => r.payload)).toContainEqual({ + kind: "account", + accountId: acc.id, + }); const audits = await ctx.db.select().from(auditLog); expect(audits.some((a) => a.action === "character.owner_mismatch")).toBe(true); - expect(audits.some((a) => a.action === "character.unlinked")).toBe(true); + expect(audits.some((a) => a.action === "character.reclaimed")).toBe(true); }); - it("falls back to invalid on an owner mismatch of the LAST character", async () => { + it("reclaims even the LAST character — the account may legitimately end empty", async () => { const acc = await seedAccount(ctx.db, { tier: "flygd" }); await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-old", }); + await createSession(ctx.db, acc.id); const at = await signAccessToken({ characterId: 1, ownerHash: "oh-NEW", scopes: [...cfg.eveSso.scopes], }); const result = await runTokenHealthJob({ db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), }); - expect(result.counts).toMatchObject({ unlinked: 0, invalid: 1 }); + expect(result.counts).toMatchObject({ unlinked: 1 }); + expect(await getChar(1)).toBeUndefined(); // gone — no last-character guard here + const [after] = await ctx.db.select().from(account); + expect(after.mainCharacterId).toBeNull(); + expect(after.tier).toBe("green"); // deprovisioned, not left flygd + expect(await ctx.db.select().from(session)).toEqual([]); + }); + + it("fails closed when the token's subject is a DIFFERENT character", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + // valid token, same owner hash, but subject character 2 — must never + // vouch for character 1's row + const at = await signAccessToken({ + characterId: 2, ownerHash: "oh-1", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.counts).toMatchObject({ invalid: 1, unlinked: 0 }); const ch = await getChar(1); - expect(ch).toBeDefined(); // link kept — reclaim happens on the new owner's login + expect(ch).toBeDefined(); // link kept expect(ch.tokenStatus).toBe("invalid"); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.subject_mismatch")).toBe(true); }); }); ``` @@ -3840,6 +4124,29 @@ Expected: FAIL (module not found). - [ ] **Step 3: Implement** +First, export the transfer-specific reclaim from `src/services/accounts.ts` — append at the end of the file (it reuses the existing internal `findCharacterForUpdate` and `reclaimCharacter` helpers; do not modify them): + +```ts +/** + * Transfer reclaim for background detection (token health): unlike + * unlinkCharacter there is NO last-character guard — that guard exists only + * for ordinary unlink flows, while a sold character always leaves its old + * account, which may legitimately end with zero characters (spec: it stays + * Green until an admin deletes it). Locks, deletes the link, applies the + * no-main rule (demotion unless tier_locked + outbox enqueue), and revokes + * the account's sessions. + */ +export async function reclaimTransferredCharacter( + dbx: DbTx, + characterId: number, +): Promise<{ ok: true } | { ok: false; error: "not_found" }> { + const existing = await findCharacterForUpdate(dbx, characterId); + if (!existing) return { ok: false, error: "not_found" }; + await reclaimCharacter(dbx, existing); + return { ok: true }; +} +``` + `src/jobs/token-health.ts`: ```ts @@ -3848,7 +4155,7 @@ import type { Config } from "@/config"; import type { Db } from "@/db"; import { character } from "@/db/schema"; import { verifyEveAccessToken } from "@/lib/esi/sso"; -import { unlinkCharacter } from "@/services/accounts"; +import { reclaimTransferredCharacter } from "@/services/accounts"; import { logAudit } from "@/services/audit"; import { runJob, type JobResult } from "@/services/sync-run"; import { getFreshAccessToken } from "@/services/tokens"; @@ -3877,30 +4184,39 @@ export async function runTokenHealthJob(deps: { } const identity = await verifyEveAccessToken(token.accessToken); + if (identity.characterId !== ch.id) { + // Fail closed: a token whose subject is another character must never + // vouch for this row (whatever produced it — bug or tampering). + await db.transaction(async (tx) => { + await tx + .update(character) + .set({ tokenStatus: "invalid" }) + .where(eq(character.id, ch.id)); + await logAudit(tx, { + actor: "system", + action: "token.subject_mismatch", + target: String(ch.id), + details: { subjectCharacterId: identity.characterId }, + }); + }); + counts.invalid++; + continue; + } + if (identity.ownerHash !== ch.ownerHash) { - // Ownership transfer (spec: Auth flows). Unlink + revoke sessions; the - // account service applies the no-main rule and fires the outbox row. - const result = await db.transaction(async (tx) => { + // Ownership transfer (spec: Auth flows): full reclaim — main cleared, + // demotion unless locked, deprovision jobs enqueued, sessions revoked. + // No last-character guard: transfer legitimately empties accounts. + await db.transaction(async (tx) => { await logAudit(tx, { actor: "system", action: "character.owner_mismatch", target: String(ch.id), details: { detectedBy: "token-health" }, }); - return unlinkCharacter(tx, cfg, "system", ch.id, { revokeSessions: true }); + await reclaimTransferredCharacter(tx, ch.id); }); - if (result.ok) { - counts.unlinked++; - } else { - // last_character: unlinking would orphan the account. Conservative - // fallback — invalidate the token and keep the link; the new owner's - // first SSO login triggers the normal reclaim path. - await db - .update(character) - .set({ tokenStatus: "invalid" }) - .where(eq(character.id, ch.id)); - counts.invalid++; - } + counts.unlinked++; continue; } @@ -3936,16 +4252,16 @@ export async function runTokenHealthJob(deps: { } ``` -- [ ] **Step 4: Run test to verify pass** +- [ ] **Step 4: Run tests to verify pass (including the accounts suite — accounts.ts changed)** -Run: `npm test -- tests/token-health-job.test.ts` +Run: `npm test -- tests/token-health-job.test.ts tests/accounts.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash -git add src/jobs/token-health.ts tests/token-health-job.test.ts -git commit -m "feat: daily token health job with transfer detection" +git add src/jobs/token-health.ts src/services/accounts.ts tests/token-health-job.test.ts +git commit -m "feat: daily token health job with transfer reclaim and subject binding" ``` --- @@ -4375,13 +4691,14 @@ git commit -m "feat: transactional outbox dispatcher with singleton fan-out" **Files:** - Modify: `src/worker/queues.ts` (add `createQueues` + `scheduleJobs`), `package.json` (add `"worker": "tsx src/worker/index.ts"` to scripts) -- Create: `src/worker/index.ts` +- Create: `src/worker/handlers.ts`, `src/worker/index.ts` - Test: `tests/worker-queues.test.ts` **Interfaces:** - Consumes: everything from Tasks 6–13; `PgBoss` from `pg-boss`. - Produces: - - `createQueues(boss: PgBoss): Promise` — creates the dead-letter queue plus all seven job queues with `{ retryLimit: 5, retryDelay: 60, retryBackoff: true, deadLetter: "ops-dead-letter" }`. + - `createQueues(boss: PgBoss): Promise` — creates the dead-letter queue plus all seven job queues with `{ policy: "short", retryLimit: 5, retryDelay: 60, retryBackoff: true, deadLetter: "ops-dead-letter" }`. **`policy: "short"` is load-bearing:** pg-boss enforces singletonKey uniqueness only via the `job_i1` partial index scoped to that policy — on the default `standard` policy singletonKey coalesces nothing (see Global Constraints). + - `type JobDeps = { db: Db; cfg: Config; esi: Pick & ContactsEsi; wanderer: WandererClient; discord: DiscordClient; fetchImpl?: typeof fetch }` and `buildJobHandlers(deps: JobDeps): Record Promise>` in `src/worker/handlers.ts` — one handler per job queue, each zod-parsing its payload (fail closed) and invoking the job. This is the seam Task 15 uses to drive dispatcher-emitted payloads through the REAL worker routing; `src/worker/index.ts` registers the same handlers with `boss.work`. - `scheduleJobs(boss: PgBoss): Promise` — cron per spec: membership `*/30 * * * *`; membership-recheck (weekly `affiliation_invalid` recheck) `0 4 * * 0`; contacts `5 * * * *`; wanderer `10 * * * *`; discord-roles `15 * * * *`; token-health `0 3 * * *`; purge `30 3 * * *`. (pg-boss supports ONE schedule per queue — that's why the weekly recheck is its own queue.) - `src/worker/index.ts` — the worker container entrypoint: starts pg-boss on `cfg.databaseUrl`, creates queues, registers `boss.work` handlers (zod-parsing job data), registers the dead-letter handler (posts ops webhook naming `data.jobType`), applies schedules, starts the dispatcher, and shuts down cleanly on SIGTERM/SIGINT. @@ -4461,8 +4778,15 @@ const JOB_QUEUES = [ export async function createQueues(boss: PgBoss): Promise { await boss.createQueue(QUEUES.deadLetter); for (const name of JOB_QUEUES) { + // policy "short": singletonKey uniqueness only exists under this policy + // (pg-boss job_i1 partial index) — standard queues ignore singletonKey. // Final-retry failures dead-letter into ops-dead-letter → ops webhook. - await boss.createQueue(name, { name, ...RETRY, deadLetter: QUEUES.deadLetter }); + await boss.createQueue(name, { + name, + policy: "short", + ...RETRY, + deadLetter: QUEUES.deadLetter, + }); } } @@ -4486,69 +4810,113 @@ export async function scheduleJobs(boss: PgBoss): Promise { (If the installed pg-boss v10 typings reject `name` inside the options object, drop that property — keep the retry + deadLetter options. Do not downgrade to positional/implicit queue creation.) -`src/worker/index.ts`: +`src/worker/handlers.ts` (the routing seam: `boss.work` and Task 15 both drive these): ```ts -import PgBoss from "pg-boss"; import { z } from "zod"; -import { getConfig } from "@/config"; -import { createDb } from "@/db"; -import { runContactsJob } from "@/jobs/contacts"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { runContactsJob, type ContactsEsi } from "@/jobs/contacts"; import { runDiscordRolesJob } from "@/jobs/discord-roles"; import { runMembershipJob } from "@/jobs/membership"; import { runPurgeJob } from "@/jobs/purge"; import { runTokenHealthJob } from "@/jobs/token-health"; import { runWandererJob } from "@/jobs/wanderer"; +import type { DiscordClient } from "@/lib/discord/rest"; +import type { EsiClient } from "@/lib/esi/client"; +import type { WandererClient } from "@/lib/wanderer/client"; +import { QUEUES } from "@/worker/queues"; + +const accountScopedSchema = z.object({ accountId: z.string().uuid().optional() }); +const discordJobSchema = z.object({ + accountId: z.string().uuid().optional(), + discordUserId: z.string().optional(), +}); + +export type JobDeps = { + db: Db; + cfg: Config; + esi: Pick & ContactsEsi; + wanderer: WandererClient; + discord: DiscordClient; + fetchImpl?: typeof fetch; +}; + +/** + * One handler per job queue: parse the payload (fail closed — an unparseable + * payload throws and the job retries into the dead-letter alert) and run the + * job. The worker registers these with boss.work; tests drive them directly + * with dispatcher-emitted payloads, so routing and parsing stay covered. + */ +export function buildJobHandlers( + deps: JobDeps, +): Record Promise> { + return { + [QUEUES.membership]: async (data) => { + const { accountId } = accountScopedSchema.parse(data); + await runMembershipJob(deps, { accountId }); + }, + [QUEUES.membershipRecheck]: async () => { + await runMembershipJob(deps, { recheckInvalid: true }); + }, + [QUEUES.contacts]: async () => { + await runContactsJob(deps); + }, + [QUEUES.wanderer]: async () => { + await runWandererJob(deps); + }, + [QUEUES.discordRoles]: async (data) => { + await runDiscordRolesJob(deps, discordJobSchema.parse(data)); + }, + [QUEUES.tokenHealth]: async () => { + await runTokenHealthJob(deps); + }, + [QUEUES.purge]: async () => { + await runPurgeJob(deps); + }, + }; +} +``` + +(The zod schemas intentionally ignore the extra `jobType` field every payload carries — zod objects strip unknown keys by default.) + +`src/worker/index.ts`: + +```ts +import PgBoss from "pg-boss"; +import { z } from "zod"; +import { getConfig } from "@/config"; +import { createDb } from "@/db"; import { createDiscordClient } from "@/lib/discord/rest"; import { createEsiClient } from "@/lib/esi/client"; import { postOpsWebhook } from "@/lib/ops-webhook"; import { createWandererClient } from "@/lib/wanderer/client"; import { startDispatcher } from "@/worker/dispatcher"; +import { buildJobHandlers } from "@/worker/handlers"; import { QUEUES, createQueues, scheduleJobs } from "@/worker/queues"; -const accountScopedSchema = z.object({ accountId: z.string().uuid().optional() }); -const discordJobSchema = z.object({ - accountId: z.string().uuid().optional(), - discordUserId: z.string().optional(), -}); const deadLetterSchema = z.object({ jobType: z.string().optional() }).nullish(); async function main(): Promise { const cfg = getConfig(); const { db, pool } = createDb(cfg.databaseUrl); - const esi = createEsiClient(); - const wanderer = createWandererClient(cfg); - const discord = createDiscordClient(cfg); const boss = new PgBoss({ connectionString: cfg.databaseUrl }); boss.on("error", (err) => console.error("pg-boss error", err)); await boss.start(); await createQueues(boss); - // pg-boss v10 handlers receive an ARRAY of jobs. - await boss.work(QUEUES.membership, async ([job]) => { - const data = accountScopedSchema.parse(job.data); - await runMembershipJob({ db, cfg, esi }, { accountId: data.accountId }); - }); - await boss.work(QUEUES.membershipRecheck, async () => { - await runMembershipJob({ db, cfg, esi }, { recheckInvalid: true }); - }); - await boss.work(QUEUES.contacts, async () => { - await runContactsJob({ db, cfg, esi }); - }); - await boss.work(QUEUES.wanderer, async () => { - await runWandererJob({ db, wanderer }); - }); - await boss.work(QUEUES.discordRoles, async ([job]) => { - const data = discordJobSchema.parse(job.data); - await runDiscordRolesJob({ db, cfg, discord }, data); - }); - await boss.work(QUEUES.tokenHealth, async () => { - await runTokenHealthJob({ db, cfg }); - }); - await boss.work(QUEUES.purge, async () => { - await runPurgeJob({ db }); + const handlers = buildJobHandlers({ + db, + cfg, + esi: createEsiClient(), + wanderer: createWandererClient(cfg), + discord: createDiscordClient(cfg), }); + // pg-boss v10 handlers receive an ARRAY of jobs. + for (const [queue, handler] of Object.entries(handlers)) { + await boss.work(queue, async ([job]) => handler(job.data)); + } // Ops alerting (spec: Error handling): a job landing here exhausted its // retries — post to the optional Discord ops webhook. @@ -4596,7 +4964,7 @@ Expected: PASS, typecheck clean. (If pg-boss typings disagree on minor option sh - [ ] **Step 5: Commit** ```bash -git add src/worker/queues.ts src/worker/index.ts package.json tests/worker-queues.test.ts +git add src/worker/queues.ts src/worker/handlers.ts src/worker/index.ts package.json tests/worker-queues.test.ts git commit -m "feat: pg-boss worker entry with schedules and dead-letter ops alerts" ``` @@ -4604,13 +4972,13 @@ git commit -m "feat: pg-boss worker entry with schedules and dead-letter ops ale ### Task 15: Full deprovision-path integration test and wrap-up verification -The spec's required integration case: main leaves alliance → green → contact removals + ACL removals + role change + audit rows, driven through the real outbox dispatcher. +The spec's required integration case: main leaves alliance → green → contact removals + ACL removals + role change + audit rows — driven through the real outbox dispatcher AND the real worker routing: every dispatcher-emitted payload is consumed by `buildJobHandlers` (payload parsing + queue routing + job invocation), not by calling jobs manually. **Files:** - Test: `tests/deprovision-flow.test.ts` **Interfaces:** -- Consumes: `runMembershipJob`, `runContactsJob`, `runWandererJob`, `runDiscordRolesJob`, `dispatchOutbox`, seed helpers, fake clients (same shapes as Tasks 8–10 tests). +- Consumes: `dispatchOutbox`, `buildJobHandlers`/`JobDeps` (Task 14), seed helpers, fake clients (same shapes as Tasks 8–10 tests). - [ ] **Step 1: Write the integration test** @@ -4618,16 +4986,13 @@ The spec's required integration case: main leaves alliance → green → contact ```ts import { sql } from "drizzle-orm"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; import { auditLog, wandererAclObservation } from "@/db/schema"; -import { runContactsJob, type ContactsEsi } from "@/jobs/contacts"; -import { runDiscordRolesJob } from "@/jobs/discord-roles"; -import { runMembershipJob } from "@/jobs/membership"; -import { runWandererJob } from "@/jobs/wanderer"; import type { DiscordClient } from "@/lib/discord/rest"; import type { Affiliation } from "@/lib/esi/client"; import type { WandererClient } from "@/lib/wanderer/client"; import { dispatchOutbox } from "@/worker/dispatcher"; +import { buildJobHandlers, type JobDeps } from "@/worker/handlers"; import { setupTestDb } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -4663,31 +5028,18 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang const stayer = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u-stayer" }); await seedCharacter(ctx.db, cfg, { id: 20, accountId: stayer.id, main: true }); - // 1) Membership: leaver's main left the alliance; stayer's main is still in. - const esiAffiliation = { + // --- fake integrations (same shapes as the Task 8–10 tests) --- + // ESI affiliation: leaver's main left the alliance; stayer's main is still in. + const contactWrites = { deletes: [] as number[][], adds: [] as number[][] }; + const esi: JobDeps["esi"] = { postAffiliation: async (ids: number[]): Promise => ids.map((id) => ({ characterId: id, corporationId: 1, allianceId: id === 20 ? 99000001 : null, })), - }; - await runMembershipJob({ db: ctx.db, cfg, esi: esiAffiliation }); - - // 2) The demotion's outbox row fans out through the real dispatcher. - const sent: Array<{ queue: string }> = []; - const dispatched = await dispatchOutbox(ctx.db, async (queue) => { - sent.push({ queue }); - }); - expect(dispatched).toBeGreaterThanOrEqual(1); - expect(new Set(sent.map((s) => s.queue))).toEqual( - new Set(["membership", "contacts", "wanderer", "discord-roles"]), - ); - - // 3) Contact push: stayer's char 20 currently has 10 and 11 under our label. - const contactWrites = { deletes: [] as number[][], adds: [] as number[][] }; - const contactsEsi: ContactsEsi = { getContactLabels: async () => [{ labelId: LABEL_ID, labelName: "flygd" }], + // stayer's char 20 currently has 10 and 11 under our label getAllContacts: async (characterId) => characterId === 20 ? [ @@ -4703,30 +5055,24 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang contactWrites.deletes.push(ids); }, }; - await runContactsJob({ db: ctx.db, cfg, esi: contactsEsi, fetchImpl: okToken }); - // automatic removal (req. 3): the leaver's chars are deleted from 20's contacts - expect(contactWrites.deletes).toContainEqual([10, 11]); - // 4) Wanderer: ACL still lists the leaver's chars → removed, observation fresh. - let aclMembers = [ - { characterId: 10, role: "member" }, - { characterId: 11, role: "member" }, - { characterId: 20, role: "member" }, + // Wanderer: the ACL still lists the leaver's chars. + let aclMembers: Array<{ characterId: number | null; role: string }> = [ + { characterId: 10, role: "viewer" }, + { characterId: 11, role: "viewer" }, + { characterId: 20, role: "viewer" }, ]; const wanderer: WandererClient = { getAclMembers: async () => [...aclMembers], addAclMember: async (id) => { - aclMembers.push({ characterId: id, role: "member" }); + aclMembers.push({ characterId: id, role: "viewer" }); }, removeAclMember: async (id) => { aclMembers = aclMembers.filter((m) => m.characterId !== id); }, }; - await runWandererJob({ db: ctx.db, wanderer }); - const observed = await ctx.db.select().from(wandererAclObservation); - expect(observed.map((o) => o.characterId)).toEqual([20]); - // 5) Discord roles: leaver ends with EXACTLY green; stayer keeps flygd. + // Discord: both users currently carry the FlyGD role. const MANAGE_ROLES = String(1 << 28); const roleOps = { added: [] as Array<[string, string]>, removed: [] as Array<[string, string]> }; const memberRoles: Record = { @@ -4751,12 +5097,52 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang roleOps.removed.push([userId, roleId]); }, }; - await runDiscordRolesJob({ db: ctx.db, cfg, discord }); + + // The REAL worker routing: every payload below goes through these handlers. + const handlers = buildJobHandlers({ + db: ctx.db, + cfg, + esi, + wanderer, + discord, + fetchImpl: okToken, + }); + + // 1) A scheduled membership run demotes the leaver (green + outbox row). + await handlers["membership"]({ jobType: "membership" }); + + // 2) The demotion's outbox row fans out through the real dispatcher… + const sent: Array<{ queue: string; data: Record }> = []; + const dispatched = await dispatchOutbox(ctx.db, async (queue, data) => { + sent.push({ queue, data }); + }); + expect(dispatched).toBeGreaterThanOrEqual(1); + expect(new Set(sent.map((s) => s.queue))).toEqual( + new Set(["membership", "contacts", "wanderer", "discord-roles"]), + ); + + // 3) …and every emitted payload is consumed by the real worker routing + // (payload parsing + queue → job wiring), not by manual job calls. + for (const msg of sent) { + const handler = handlers[msg.queue]; + expect(handler, `no handler for queue ${msg.queue}`).toBeDefined(); + await handler(msg.data); + } + + // 4) Automatic removal (req. 3): leaver's chars deleted from 20's contacts. + expect(contactWrites.deletes).toContainEqual([10, 11]); + + // 5) Wanderer: leaver's chars removed; observation is the post-mutation read. + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => o.characterId)).toEqual([20]); + + // 6) Discord: leaver ends with EXACTLY green; stayer untouched (the fan-out + // was scoped to the demoted account). expect(roleOps.added).toContainEqual(["u-leaver", "12"]); expect(roleOps.removed).toContainEqual(["u-leaver", "10"]); expect(roleOps.added).not.toContainEqual(["u-stayer", "12"]); - // 6) Audit trail: demotion cause + downstream actions all recorded. + // 7) Audit trail: demotion cause + downstream actions all recorded. const audits = await ctx.db.select().from(auditLog); const tierChange = audits.find((a) => a.action === "tier.changed"); expect(tierChange?.details).toMatchObject({ to: "green", cause: "main left alliance" }); @@ -4768,7 +5154,7 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang - [ ] **Step 2: Run the test** Run: `npm test -- tests/deprovision-flow.test.ts` -Expected: PASS (everything it exercises was built in Tasks 6–13; failures here are integration bugs — fix them, do not weaken the test). +Expected: PASS (everything it exercises was built in Tasks 6–14; failures here are integration bugs — fix them, do not weaken the test). - [ ] **Step 3: Full verification** @@ -4789,5 +5175,5 @@ git commit -m "test: full deprovision-path integration coverage" - Admin UI: accounts page (tier/lock controls, cryo + notes, sort/filter, Map + last-login columns), audit log page, sync status page reading `sync_run` with "sync now" buttons (which enqueue `{kind:"all"}` / account-scoped outbox rows — the dispatcher built here already handles them). - Admin route gating for `demoteAdmin` (+ `ORDER BY account.id` on its multi-row `FOR UPDATE` — carry-over). - Dockerfile + deploy config (web + worker containers from one image; worker start command `npm run worker`), Playwright smoke tests. -- **Deploy-time verification of the assumed Wanderer API contract** (paths/shapes in `src/lib/wanderer/client.ts`). +- A deploy-time smoke check of the Wanderer client against the live instance (the contract is confirmed from wanderer source, but a live read/add/remove pass at first deploy is cheap insurance). - Login page `error` search param wiring (carry-over UI polish item). From fffb9e845108e0d000c37edc9b0a745e23b04f16 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:03:55 -0400 Subject: [PATCH 03/28] docs: plan 2 round-2 review fixes (stale-write guards, wanderer unblock + strict schema, fail-closed payloads, schedule singleton keys, relink resync) --- .../plans/2026-08-02-authgd-2-sync-engine.md | 535 ++++++++++++++---- 1 file changed, 419 insertions(+), 116 deletions(-) diff --git a/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md b/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md index 044db6b4..90a52f1c 100644 --- a/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md +++ b/docs/superpowers/plans/2026-08-02-authgd-2-sync-engine.md @@ -1222,8 +1222,10 @@ git commit -m "feat: affiliation chunk/bisect resolution and tier decision rule" - Consumes: `decryptToken`/`encryptToken` (`src/lib/crypto.ts`), `refreshEveToken`/`EveSsoError` (`src/lib/esi/sso.ts`), `classifyOAuthError` (`src/core/errors.ts`), `logAudit`. - Produces: - `type CharacterTokenRow = { id: number; refreshTokenEnc: string | null; tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing" }` - - `getFreshAccessToken(db: Db, cfg: Config, ch: CharacterTokenRow, fetchImpl?: typeof fetch): Promise` where `type AccessTokenResult = { ok: true; accessToken: string } | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }` — refreshes via `refreshEveToken`, persists the rotated refresh token on success. Permanent OAuth failures AND malformed stored blobs (carry-over: `decryptToken` throws uncleanly) mark `token_status: invalid` + audit `token.invalidated`; transient failures change no state. Used by Tasks 8 and 11. - - **Concurrency (EVE rotates refresh tokens on every use, and overlapping jobs may race):** the success path persists with compare-and-swap on the blob that was read (`WHERE refresh_token_enc = `); a lost race keeps the first writer's stored token and still returns `ok`. The permanent-failure path re-reads the row first: if the stored blob changed since our read, a concurrent job already rotated it — `invalid_grant` on the OLD token says nothing about the NEW one, so return `transient` and do NOT invalidate. + - `getFreshAccessToken(db: Db, cfg: Config, ch: CharacterTokenRow, fetchImpl?: typeof fetch): Promise` where `type AccessTokenResult = { ok: true; accessToken: string; tokenEnc: string } | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }` — refreshes via `refreshEveToken`, persists the rotated refresh token on success and returns the NEW stored blob as `tokenEnc` **so callers can condition their own follow-up writes on it** (Task 11 does). Permanent OAuth failures AND malformed stored blobs (carry-over: `decryptToken` throws uncleanly) mark `token_status: invalid` + audit `token.invalidated`; transient failures change no state. Used by Tasks 8 and 11. + - **Concurrency (EVE rotates refresh tokens on every use; overlapping jobs — or a transfer reclaim — may race):** + - The success path persists with compare-and-swap on the blob that was read (`WHERE refresh_token_enc = ` + `RETURNING`). **A CAS miss is returned as `transient`, never `ok`** — the row changed underneath us (rotation, re-auth, or reclaim by a new owner), so a stale decision must not act on it. + - Invalidation is ONE conditional transaction: `UPDATE … SET token_status='invalid' WHERE id = ? AND refresh_token_enc = RETURNING`, auditing only when the update wins; a guard miss returns `transient` and changes nothing. - Test seed helpers in `tests/helpers/seed.ts`: `seedAccount(db, opts?)` and `seedCharacter(db, cfg, opts)` (exact signatures in code below). Later test tasks consume these. - [ ] **Step 1: Write the seed helper and failing test** @@ -1346,14 +1348,16 @@ async function getChar(id: number) { } describe("getFreshAccessToken", () => { - it("returns the access token and persists the rotated refresh token", async () => { + it("returns the access token + stored blob and persists the rotated refresh token", async () => { const ch = await seed({ refreshToken: "old-rt" }); const fetchImpl = (async () => tokenJson({ access_token: "new-at", refresh_token: "new-rt" })) as typeof fetch; const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); - expect(r).toEqual({ ok: true, accessToken: "new-at" }); + expect(r).toMatchObject({ ok: true, accessToken: "new-at" }); const updated = await getChar(90000001); expect(decryptToken(updated.refreshTokenEnc as string, cfg.tokenEncryptionKey)).toBe("new-rt"); + // tokenEnc is exactly what is now stored — callers guard follow-up writes on it + expect(r).toMatchObject({ tokenEnc: updated.refreshTokenEnc }); }); it("marks token invalid + audits on permanent OAuth errors", async () => { @@ -1399,7 +1403,7 @@ describe("getFreshAccessToken", () => { expect(r).toEqual({ ok: false, reason: "no_token" }); }); - it("does not clobber a concurrently rotated token on success (CAS)", async () => { + it("treats a CAS miss on success as TRANSIENT — a stale token is never usable", async () => { const stale = await seed({ refreshToken: "old-rt" }); // row as WE read it // another job rotates underneath us before our refresh completes const currentBlob = encryptToken("current-rt", cfg.tokenEncryptionKey); @@ -1410,9 +1414,11 @@ describe("getFreshAccessToken", () => { const fetchImpl = (async () => tokenJson({ access_token: "our-at", refresh_token: "our-rt" })) as typeof fetch; const r = await getFreshAccessToken(ctx.db, cfg, stale, fetchImpl); - expect(r).toEqual({ ok: true, accessToken: "our-at" }); // our access token still works + // the row changed hands under us — our whole read is stale, so downstream + // callers must NOT act on this character this run + expect(r).toMatchObject({ ok: false, reason: "transient" }); const after = await getChar(90000001); - // …but the FIRST writer's stored refresh token wins + // the first writer's stored refresh token wins expect(decryptToken(after.refreshTokenEnc as string, cfg.tokenEncryptionKey)).toBe("current-rt"); }); @@ -1458,21 +1464,37 @@ export type CharacterTokenRow = { }; export type AccessTokenResult = - | { ok: true; accessToken: string } + | { ok: true; accessToken: string; tokenEnc: string } | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }; -async function markInvalid(db: Db, characterId: number, reason: string): Promise { - await db.transaction(async (tx) => { - await tx +/** + * Marks the token invalid ONLY if the stored blob is still the one this + * decision was based on — one conditional transaction, auditing only when the + * guard wins. A miss means the row changed underneath us (rotation, re-auth, + * or transfer reclaim): the stale decision is discarded. + */ +async function invalidateIfUnchanged( + db: Db, + characterId: number, + expectedEnc: string, + reason: string, +): Promise { + return db.transaction(async (tx) => { + const rows = await tx .update(character) .set({ tokenStatus: "invalid" }) - .where(eq(character.id, characterId)); + .where( + and(eq(character.id, characterId), eq(character.refreshTokenEnc, expectedEnc)), + ) + .returning({ id: character.id }); + if (rows.length === 0) return false; await logAudit(tx, { actor: "system", action: "token.invalidated", target: String(characterId), details: { reason }, }); + return true; }); } @@ -1494,38 +1516,46 @@ export async function getFreshAccessToken( try { refreshToken = decryptToken(ch.refreshTokenEnc, cfg.tokenEncryptionKey); } catch { - await markInvalid(db, ch.id, "malformed_token_blob"); - return { ok: false, reason: "invalid", detail: "malformed_token_blob" }; + const applied = await invalidateIfUnchanged(db, ch.id, ch.refreshTokenEnc, "malformed_token_blob"); + return applied + ? { ok: false, reason: "invalid", detail: "malformed_token_blob" } + : { ok: false, reason: "transient", detail: "concurrent rotation" }; } try { const r = await refreshEveToken(cfg, refreshToken, fetchImpl); // Compare-and-swap on the blob we read: EVE rotates refresh tokens on - // every use, so a concurrent job may have rotated first. If the CAS - // misses, keep the first writer's stored token — our access token is - // still valid for this run. - await db + // every use, so a concurrent job (or a transfer reclaim) may have won the + // row first. A miss means our whole read is stale — report transient and + // let the next run work from fresh state; never hand out the stale token. + const tokenEnc = encryptToken(r.refreshToken, cfg.tokenEncryptionKey); + const rows = await db .update(character) - .set({ refreshTokenEnc: encryptToken(r.refreshToken, cfg.tokenEncryptionKey) }) + .set({ refreshTokenEnc: tokenEnc }) .where( and(eq(character.id, ch.id), eq(character.refreshTokenEnc, ch.refreshTokenEnc)), - ); - return { ok: true, accessToken: r.accessToken }; + ) + .returning({ id: character.id }); + if (rows.length === 0) { + return { ok: false, reason: "transient", detail: "concurrent rotation" }; + } + return { ok: true, accessToken: r.accessToken, tokenEnc }; } catch (err) { if ( err instanceof EveSsoError && classifyOAuthError(err.oauthError, err.status) === "permanent" ) { // invalid_grant on the OLD blob says nothing about a token another job - // rotated in the meantime — re-read before invalidating. - const [current] = await db - .select({ refreshTokenEnc: character.refreshTokenEnc }) - .from(character) - .where(eq(character.id, ch.id)); - if (!current || current.refreshTokenEnc !== ch.refreshTokenEnc) { - return { ok: false, reason: "transient", detail: "concurrent rotation" }; - } - await markInvalid(db, ch.id, err.oauthError ?? `status_${err.status}`); - return { ok: false, reason: "invalid", detail: err.oauthError }; + // rotated in the meantime — the conditional update discards the stale + // decision atomically (no separate read-then-write window). + const applied = await invalidateIfUnchanged( + db, + ch.id, + ch.refreshTokenEnc, + err.oauthError ?? `status_${err.status}`, + ); + return applied + ? { ok: false, reason: "invalid", detail: err.oauthError } + : { ok: false, reason: "transient", detail: "concurrent rotation" }; } return { ok: false, @@ -2596,11 +2626,13 @@ git commit -m "feat: per-character contact push with label ownership and abort-o - Consumes: `Config["wanderer"]`, `getFlygdCharacters`, `runJob`, `wandererAclObservation` table, `logAudit`. - Produces: - `class WandererError extends Error { status?: number; transient: boolean }` (429/5xx/network → transient; other HTTP → permanent). + - `type AclRole = "admin" | "manager" | "member" | "viewer" | "blocked"` (the documented role enum — parsing is strict, so an unexpected role spelling fails the WHOLE read closed rather than silently losing admin protection). - `createWandererClient(cfg: Config, fetchImpl?: typeof fetch)` / `type WandererClient` — the confirmed contract (see Global Constraints): - - `getAclMembers(): Promise>` — `GET {base}/api/acls/{aclId}`, members under `data.members`. Members carrying `eve_corporation_id`/`eve_alliance_id` instead of `eve_character_id` are returned with `characterId: null` (NOT rejected as malformed). EVE ids accepted as digit-string or number. + - `getAclMembers(): Promise>` — `GET {base}/api/acls/{aclId}`, members under `data.members`. **Each member must carry EXACTLY ONE of the three external ids** (zero or multiple → parse failure, fail closed); corporation/alliance members are returned with `characterId: null`. EVE ids accepted as digit-string or number. - `addAclMember(characterId: number): Promise` — `POST {base}/api/acls/{aclId}/members` with `{ member: { eve_character_id: String(id), role: "viewer" } }`; the name is resolved server-side and never sent. + - `updateAclMemberRole(characterId: number, role: AclRole): Promise` — `PUT {base}/api/acls/{aclId}/members/{characterId}` with `{ member: { role } }` (confirmed contract; keyed by EVE id). - `removeAclMember(characterId: number): Promise` — `DELETE {base}/api/acls/{aclId}/members/{characterId}` (the EVE id, not the member UUID); **404 = already not a member = idempotent success**. - - `type AclMember = { characterId: number; role: string }`; `diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; remove: number[] }` — **`admin`-role entries are never removed; `manager` entries are removable like anyone else.** Callers pass ONLY character entries. + - `type AclMember = { characterId: number; role: string }`; `diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; remove: number[]; unblock: number[] }` — **`admin`-role entries are never removed; `manager` entries are removable like anyone else.** `unblock` lists DESIRED characters whose current role is `blocked` — a blocked entry grants no access, so presence alone is not convergence; the job resets them to `viewer` via PUT. Elevated/normal roles (admin, manager, member, viewer) are otherwise preserved as-is. Callers pass ONLY character entries. - `runWandererJob(deps: { db: Db; wanderer: WandererClient }): Promise` — job type `"wanderer"`. **Corporation/alliance ACL entries (`characterId: null`) are filtered out before diffing — never added, removed, or observed.** Read fails → `failed` before ANY mutation (never remove on unknown state), retry per the error's transience. After any mutation (or partial failure), **re-read the ACL and persist THAT read's character entries** wholesale into `wanderer_acl_observation`; when nothing was mutated, persist the initial read. If the post-mutation re-read fails, the observation is left untouched (stale-but-honest). **Classification is preserved end-to-end:** `retry` is set only when at least one failure (mutation or re-read) was transient — all-permanent failures finish `partial` WITHOUT retry. Audits `wanderer.added` / `wanderer.removed` per successful mutation. - [ ] **Step 1: Write failing tests** @@ -2621,7 +2653,7 @@ describe("diffAcl", () => { { characterId: 3, role: "member" }, ], }), - ).toEqual({ add: [1], remove: [3] }); + ).toEqual({ add: [1], remove: [3], unblock: [] }); }); it("NEVER removes admin-role entries; managers are removable", () => { @@ -2634,13 +2666,28 @@ describe("diffAcl", () => { { characterId: 3, role: "member" }, ], }), - ).toEqual({ add: [], remove: [2, 3] }); + ).toEqual({ add: [], remove: [2, 3], unblock: [] }); + }); + + it("unblocks desired blocked members, preserving all other roles", () => { + expect( + diffAcl({ + desiredIds: [1, 2, 3, 4], + members: [ + { characterId: 1, role: "blocked" }, // desired but blocked → unblock + { characterId: 2, role: "manager" }, // elevated → preserved + { characterId: 3, role: "viewer" }, // normal → preserved + { characterId: 4, role: "admin" }, // elevated → preserved + { characterId: 5, role: "blocked" }, // blocked AND undesired → removed + ], + }), + ).toEqual({ add: [], remove: [5], unblock: [1] }); }); it("is a no-op when converged", () => { expect( diffAcl({ desiredIds: [1], members: [{ characterId: 1, role: "member" }] }), - ).toEqual({ add: [], remove: [] }); + ).toEqual({ add: [], remove: [], unblock: [] }); }); }); ``` @@ -2693,12 +2740,51 @@ describe("createWandererClient", () => { it("fails closed on malformed member payloads", async () => { server.use( http.get(ACL, () => - aclResponse([{ eve_character_id: "not-digits", role: "x" }]), + aclResponse([{ eve_character_id: "not-digits", role: "viewer" }]), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + + it("fails closed on an unknown role spelling (admin protection depends on it)", async () => { + server.use( + http.get(ACL, () => + aclResponse([{ eve_character_id: "90000001", role: "administrator" }]), ), ); await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); }); + it("fails closed on zero or multiple external ids", async () => { + server.use(http.get(ACL, () => aclResponse([{ role: "viewer" }]))); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + server.use( + http.get(ACL, () => + aclResponse([ + { eve_character_id: "1", eve_corporation_id: "2", role: "viewer" }, + ]), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + + it("updates a member's role via PUT keyed by EVE id", async () => { + let putId = ""; + let putBody: unknown; + server.use( + http.put(`${MEMBERS}/:id`, async ({ params, request }) => { + putId = params.id as string; + putBody = await request.json(); + return HttpResponse.json({ + data: { id: "uuid", name: "Pilot", role: "viewer", eve_character_id: putId }, + }); + }), + ); + await createWandererClient(cfg).updateAclMemberRole(90000006, "viewer"); + expect(putId).toBe("90000006"); + expect(putBody).toEqual({ member: { role: "viewer" } }); + }); + it("classifies 5xx as transient and 403 as permanent", async () => { server.use(http.get(ACL, () => HttpResponse.json({}, { status: 502 }))); let err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); @@ -2753,7 +2839,11 @@ import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { auditLog, wandererAclObservation } from "@/db/schema"; import { runWandererJob } from "@/jobs/wanderer"; -import { WandererError, type WandererClient } from "@/lib/wanderer/client"; +import { + WandererError, + type WandererAclMember, + type WandererClient, +} from "@/lib/wanderer/client"; import { JobRetryError } from "@/services/sync-run"; import { setupTestDb } from "./helpers/db"; import { testConfig } from "./helpers/config"; @@ -2774,7 +2864,7 @@ beforeEach(async () => { `); }); -type Member = { characterId: number | null; role: string }; +type Member = WandererAclMember; /** Fake Wanderer with a mutable member list and scriptable failures. */ function fakeWanderer(initial: Member[], opts: { @@ -2800,6 +2890,9 @@ function fakeWanderer(initial: Member[], opts: { addAclMember: async (id) => { members.push({ characterId: id, role: "viewer" }); }, + updateAclMemberRole: async (id, role) => { + members = members.map((m) => (m.characterId === id ? { ...m, role } : m)); + }, removeAclMember: async (id) => { if (opts.failRemoveOf === id) { throw new WandererError("remove failed", { @@ -2882,6 +2975,19 @@ describe("runWandererJob", () => { expect(observed.map((o) => o.characterId).sort((a, b) => a - b)).toEqual([1, 5]); }); + it("unblocks a desired blocked member and observes the new role", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 1, role: "blocked" }]); + const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ unblocked: 1, added: 0, removed: 0 }); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ characterId: 1, role: "viewer" }); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "wanderer.unblocked")).toBe(true); + }); + it("does NOT retry when every failure was permanent", async () => { await seedFlygdChar(1); const w = fakeWanderer( @@ -2928,16 +3034,21 @@ export type AclMember = { characterId: number; role: string }; /** * Spec job 3: admin-role entries are NEVER removed; manager-role entries are - * removed like anyone else when they leave the desired set. + * removed like anyone else when they leave the desired set. A desired + * character whose role is "blocked" has no effective access — presence alone + * is not convergence — so it is unblocked (reset to viewer); all other roles + * (admin/manager/member/viewer) are preserved as-is. */ export function diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; remove: number[]; + unblock: number[]; } { const desired = new Set(input.desiredIds); - const present = new Set(input.members.map((m) => m.characterId)); + const byId = new Map(input.members.map((m) => [m.characterId, m])); return { - add: input.desiredIds.filter((id) => !present.has(id)), + add: input.desiredIds.filter((id) => !byId.has(id)), + unblock: input.desiredIds.filter((id) => byId.get(id)?.role === "blocked"), remove: input.members .filter((m) => !desired.has(m.characterId) && m.role !== "admin") .map((m) => m.characterId), @@ -2971,20 +3082,31 @@ export class WandererError extends Error { } const eveIdSchema = z.union([z.string().regex(/^\d+$/), z.number().int()]); +const roleSchema = z.enum(["admin", "manager", "member", "viewer", "blocked"]); +// Strict on both axes, fail closed: an unknown role spelling could cost an +// entry its admin protection, and a member with zero/multiple external ids +// violates the documented contract — either rejects the WHOLE read, so the +// job never mutates from a misunderstood ACL. +const memberSchema = z + .object({ + role: roleSchema, + eve_character_id: eveIdSchema.nullish(), + eve_corporation_id: eveIdSchema.nullish(), + eve_alliance_id: eveIdSchema.nullish(), + }) + .refine( + (m) => + [m.eve_character_id, m.eve_corporation_id, m.eve_alliance_id].filter( + (v) => v != null, + ).length === 1, + { message: "ACL member must carry exactly one external id" }, + ); const aclSchema = z.object({ - data: z.object({ - members: z.array( - z.object({ - role: z.string(), - eve_character_id: eveIdSchema.nullish(), - eve_corporation_id: eveIdSchema.nullish(), - eve_alliance_id: eveIdSchema.nullish(), - }), - ), - }), + data: z.object({ members: z.array(memberSchema) }), }); -export type WandererAclMember = { characterId: number | null; role: string }; +export type AclRole = z.infer; +export type WandererAclMember = { characterId: number | null; role: AclRole }; export function createWandererClient(cfg: Config, fetchImpl: typeof fetch = fetch) { const base = cfg.wanderer.baseUrl.replace(/\/$/, ""); @@ -3041,6 +3163,13 @@ export function createWandererClient(cfg: Config, fetchImpl: typeof fetch = fetc }), }); }, + async updateAclMemberRole(characterId: number, role: AclRole): Promise { + // keyed by EVE id, not the member row's UUID + await request(`${membersPath}/${characterId}`, { + method: "PUT", + body: JSON.stringify({ member: { role } }), + }); + }, async removeAclMember(characterId: number): Promise { const path = `${membersPath}/${characterId}`; const res = await rawRequest(path, { method: "DELETE" }); @@ -3123,11 +3252,23 @@ export async function runWandererJob(deps: { errors.push(`remove ${id}: ${err instanceof Error ? err.message : String(err)}`); } } + // A blocked desired member has no effective access — reset to viewer. + let unblocked = 0; + for (const id of diff.unblock) { + try { + await wanderer.updateAclMemberRole(id, "viewer"); + unblocked++; + await logAudit(db, { actor: "system", action: "wanderer.unblocked", target: String(id) }); + } catch (err) { + anyTransient ||= isTransient(err); + errors.push(`unblock ${id}: ${err instanceof Error ? err.message : String(err)}`); + } + } // Persist the POST-mutation state (spec: the UI never shows pre-mutation // state). No mutation → the initial read is already the live state. let observed: typeof members | null = members; - if (added + removed > 0 || errors.length > 0) { + if (added + removed + unblocked > 0 || errors.length > 0) { try { observed = await wanderer.getAclMembers(); } catch (err) { @@ -3148,7 +3289,14 @@ export async function runWandererJob(deps: { }); } - const counts = { added, removed, addFailed: diff.add.length - added, removeFailed: diff.remove.length - removed }; + const counts = { + added, + removed, + unblocked, + addFailed: diff.add.length - added, + removeFailed: diff.remove.length - removed, + unblockFailed: diff.unblock.length - unblocked, + }; if (errors.length > 0 || observed === null) { return { status: "partial", @@ -3195,7 +3343,7 @@ git commit -m "feat: wanderer ACL sync with post-mutation observation" - `diffRoles(input: { tier: "flygd" | "blue" | "green"; managed: ManagedRoleIds; memberRoleIds: string[] }): { add: string[]; remove: string[] }` — ensure exactly the tier's role among the three managed roles; other roles untouched. - `stripManagedRoles(managed: ManagedRoleIds, memberRoleIds: string[]): string[]` — the managed roles the member currently has (for unlinked-user deprovision). - `validateRoleConfig(input: { managed: ManagedRoleIds; guildRoles: Array<{ id: string; position: number; permissions: string }>; botRoleIds: string[] }): { ok: true } | { ok: false; error: string }` — three distinct ids, all present in the guild, bot has Manage Roles (or Administrator), bot's highest role above every managed role. - - `runDiscordRolesJob(deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, opts?: { accountId?: string; discordUserId?: string }): Promise` — job type `"discord-roles"`. Config validation runs FIRST each run; validation failure is **permanent-config**: posts the ops webhook immediately and returns `failed` WITHOUT retry. **A permanent `DiscordApiError` (e.g. 401/403 — bad bot token, missing access) while FETCHING the config data takes the same permanent-config path**; only transient fetch errors propagate into a pg-boss retry. `opts.discordUserId` handles `{kind:"discord-user"}` outbox payloads: if the user is still unlinked, strip managed roles (not-in-guild → log and skip); if re-linked meanwhile, skip (the account path owns it). Otherwise iterate Discord-linked accounts (optionally scoped), ensuring exactly the tier's managed role; user-not-in-guild → count and skip; audits `discord.role_changed`. + - `runDiscordRolesJob(deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, opts?: { accountId?: string; discordUserId?: string }): Promise` — job type `"discord-roles"`. Config validation runs FIRST each run; validation failure is **permanent-config**: posts the ops webhook immediately and returns `failed` WITHOUT retry. **A permanent `DiscordApiError` (e.g. 401/403 — bad bot token, missing access) while FETCHING the config data takes the same permanent-config path**; only transient fetch errors propagate into a pg-boss retry. `opts.discordUserId` handles `{kind:"discord-user"}` outbox payloads: if the user is still unlinked, strip managed roles (not-in-guild → log and skip); if re-linked meanwhile, skip (the account path owns it). **Last-writer race guard:** after stripping, the link is re-checked — if the user re-linked WHILE the strip ran (so the strip may have clobbered a just-restored role), an `{kind:"account"}` outbox row is enqueued for that account so the account path re-syncs the roles. Otherwise iterate Discord-linked accounts (optionally scoped), ensuring exactly the tier's managed role; user-not-in-guild → count and skip; audits `discord.role_changed`. - [ ] **Step 1: Write failing tests** @@ -3375,7 +3523,7 @@ describe("createDiscordClient", () => { ```ts import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { auditLog, syncRun } from "@/db/schema"; +import { auditLog, outbox, syncRun } from "@/db/schema"; import { runDiscordRolesJob } from "@/jobs/discord-roles"; import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; import { setupTestDb } from "./helpers/db"; @@ -3519,6 +3667,30 @@ describe("runDiscordRolesJob", () => { expect(d.added).toEqual([]); }); + it("re-syncs the account when a re-link lands DURING the strip", async () => { + const d = fakeDiscord({ u9: ["10"] }); + // the re-link commits while the strip's role removal is in flight + const client: DiscordClient = { + ...d.client, + removeMemberRole: async (userId, roleId) => { + const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u9" }); + await seedCharacter(ctx.db, cfg, { id: 99, accountId: acc.id, main: true }); + await d.client.removeMemberRole(userId, roleId); + }, + }; + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: client }, + { discordUserId: "u9" }, + ); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ relinkResync: 1 }); + // the account path re-asserts the roles via a fresh outbox row + const rows = await ctx.db.select().from(outbox); + expect(rows.map((r) => r.payload)).toContainEqual( + expect.objectContaining({ kind: "account" }), + ); + }); + it("skips the strip when the user re-linked meanwhile", async () => { const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u9" }); await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); @@ -3734,6 +3906,7 @@ import { diffRoles, stripManagedRoles, validateRoleConfig } from "@/core/role-di import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; import { postOpsWebhook } from "@/lib/ops-webhook"; import { logAudit } from "@/services/audit"; +import { enqueueSync } from "@/services/outbox"; import { runJob, type JobResult } from "@/services/sync-run"; export async function runDiscordRolesJob( @@ -3797,6 +3970,17 @@ export async function runDiscordRolesJob( details: { removed: remove, cause: "discord unlinked" }, }); } + // Last-writer race: a re-link may have landed (and its role sync run) + // WHILE we stripped. Re-check; if linked now, hand ownership back to the + // account path with a fresh outbox row so the roles are re-asserted. + const relinked = await db + .select() + .from(discordLink) + .where(eq(discordLink.discordUserId, opts.discordUserId)); + if (relinked.length > 0) { + await enqueueSync(db, { kind: "account", accountId: relinked[0].accountId }); + return { status: "ok", counts: { removed: remove.length, relinkResync: 1 } }; + } return { status: "ok", counts: { removed: remove.length } }; } @@ -3886,13 +4070,14 @@ git commit -m "feat: discord role sync with permanent-config validation" **Interfaces:** - Consumes: `getFreshAccessToken`, `verifyEveAccessToken`/`setTestJwksOverride` (`src/lib/esi/sso.ts`), `runJob`, `logAudit`, and the internal `reclaimCharacter`/`findCharacterForUpdate` helpers in `src/services/accounts.ts`. - Produces: - - `reclaimTransferredCharacter(dbx: DbTx, characterId: number): Promise<{ ok: true } | { ok: false; error: "not_found" }>` exported from `src/services/accounts.ts` — transfer reclaim for background detection. **Unlike `unlinkCharacter` there is no last-character guard**: that guard exists only for ordinary unlink flows; transfer reclaim already legitimately produces zero-character accounts (spec: the account "simply stays Green until an admin deletes it"). Wraps the existing internal `reclaimCharacter` (advisory lock + row lock, delete link + contact state, audit `character.reclaimed`, no-main rule with demotion + outbox enqueue, session revocation). + - `reclaimTransferredCharacter(dbx: DbTx, characterId: number, expected: { accountId: string; ownerHash: string }): Promise<{ ok: true } | { ok: false; error: "not_found" | "changed" }>` exported from `src/services/accounts.ts` — transfer reclaim for background detection. **Unlike `unlinkCharacter` there is no last-character guard**: that guard exists only for ordinary unlink flows; transfer reclaim already legitimately produces zero-character accounts (spec: the account "simply stays Green until an admin deletes it"). **Stale-decision guard:** after taking the character lock it re-verifies the row still matches `expected` (account AND owner hash) — if the character already changed hands (e.g. the new owner's login reclaimed it first), it returns `"changed"` and deletes nothing. Wraps the existing internal `reclaimCharacter` (advisory lock + row lock, delete link + contact state, audit `character.reclaimed`, no-main rule with demotion + outbox enqueue, session revocation). - `runTokenHealthJob(deps: { db: Db; cfg: Config; fetchImpl?: typeof fetch }): Promise` — job type `"token-health"`. For every character with a stored token not already `invalid`: - - Refresh via `getFreshAccessToken` (permanent-only invalidation, CAS rotation, concurrent-rotation safety live there; transient → counted, retried at job level). + - Refresh via `getFreshAccessToken` (permanent-only invalidation, CAS rotation, concurrent-rotation safety live there; transient — including CAS misses — → counted, retried at job level). - Verify the returned access token JWT → subject character id, `ownerHash`, granted `scopes`. - - **Subject binding (fail closed):** if the JWT's character id ≠ the row's id, the token must never vouch for this row — mark `token_status: invalid` + audit `token.subject_mismatch`, keep the link, continue. - - **owner_hash mismatch** → ownership transfer: in ONE transaction, audit `character.owner_mismatch` then `reclaimTransferredCharacter(tx, ch.id)` — this deprovisions fully (main cleared, demotion unless locked, outbox row for jobs 2–4, sessions revoked) even when it is the account's last character. - - Otherwise persist current `scopes` and recompute `token_status`: full coverage of `cfg.eveSso.scopes` → `valid`, shortfall → `needs_reauth` (audit `token.needs_reauth` on transition). + - **Every follow-up write is conditioned on `token.tokenEnc`** (the blob our CAS just stored): `WHERE id = ? AND refresh_token_enc = ? RETURNING`. A guard miss means the row changed after our refresh (re-auth, reclaim by the new owner) — the stale decision is discarded and counted transient. + - **Subject binding (fail closed):** if the JWT's character id ≠ the row's id, the token must never vouch for this row — conditionally mark `token_status: invalid`, auditing `token.subject_mismatch` only when the guard wins; keep the link, continue. + - **owner_hash mismatch** → ownership transfer: in ONE transaction, `reclaimTransferredCharacter(tx, ch.id, { accountId: ch.accountId, ownerHash: ch.ownerHash })` and audit `character.owner_mismatch` only when the reclaim wins — the service re-verifies account+owner under the character lock, so a transfer that already completed concurrently is never double-deleted. Full deprovision (main cleared, demotion unless locked, outbox row for jobs 2–4, sessions revoked) even when it is the account's last character. + - Otherwise persist current `scopes` and recompute `token_status` (guarded, as above): full coverage of `cfg.eveSso.scopes` → `valid`, shortfall → `needs_reauth` (audit `token.needs_reauth` on transition). - Transient refresh failures → `partial` + retry. Counts: `refreshed`, `invalid`, `needsReauth`, `unlinked`, `skipped`. - [ ] **Step 1: Write failing test** @@ -3918,6 +4103,7 @@ import { import { account, auditLog, character, outbox, session } from "@/db/schema"; import { runTokenHealthJob } from "@/jobs/token-health"; import { setTestJwksOverride } from "@/lib/esi/sso"; +import { reclaimTransferredCharacter } from "@/services/accounts"; import { JobRetryError } from "@/services/sync-run"; import { createSession } from "@/services/session"; import { setupTestDb } from "./helpers/db"; @@ -4114,6 +4300,19 @@ describe("runTokenHealthJob", () => { const audits = await ctx.db.select().from(auditLog); expect(audits.some((a) => a.action === "token.subject_mismatch")).toBe(true); }); + + it("reclaimTransferredCharacter refuses a stale decision (row changed hands)", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + // the row's CURRENT owner hash is already the new owner's + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, ownerHash: "oh-new-owner", + }); + const r = await ctx.db.transaction((tx) => + reclaimTransferredCharacter(tx, 1, { accountId: acc.id, ownerHash: "oh-stale" }), + ); + expect(r).toEqual({ ok: false, error: "changed" }); + expect(await getChar(1)).toBeDefined(); // nothing deleted + }); }); ``` @@ -4139,9 +4338,19 @@ First, export the transfer-specific reclaim from `src/services/accounts.ts` — export async function reclaimTransferredCharacter( dbx: DbTx, characterId: number, -): Promise<{ ok: true } | { ok: false; error: "not_found" }> { + expected: { accountId: string; ownerHash: string }, +): Promise<{ ok: true } | { ok: false; error: "not_found" | "changed" }> { const existing = await findCharacterForUpdate(dbx, characterId); if (!existing) return { ok: false, error: "not_found" }; + // Stale-decision guard: re-verify under the lock. If the row already + // changed hands (the new owner's login reclaimed it, or a re-auth updated + // the owner hash), this caller's decision is based on dead data. + if ( + existing.accountId !== expected.accountId || + existing.ownerHash !== expected.ownerHash + ) { + return { ok: false, error: "changed" }; + } await reclaimCharacter(dbx, existing); return { ok: true }; } @@ -4150,7 +4359,7 @@ export async function reclaimTransferredCharacter( `src/jobs/token-health.ts`: ```ts -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { Config } from "@/config"; import type { Db } from "@/db"; import { character } from "@/db/schema"; @@ -4186,20 +4395,27 @@ export async function runTokenHealthJob(deps: { if (identity.characterId !== ch.id) { // Fail closed: a token whose subject is another character must never - // vouch for this row (whatever produced it — bug or tampering). - await db.transaction(async (tx) => { - await tx + // vouch for this row. Guard on the blob our CAS just stored so a + // concurrent re-auth/reclaim discards this stale decision. + const applied = await db.transaction(async (tx) => { + const rows = await tx .update(character) .set({ tokenStatus: "invalid" }) - .where(eq(character.id, ch.id)); + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), + ) + .returning({ id: character.id }); + if (rows.length === 0) return false; await logAudit(tx, { actor: "system", action: "token.subject_mismatch", target: String(ch.id), details: { subjectCharacterId: identity.characterId }, }); + return true; }); - counts.invalid++; + if (applied) counts.invalid++; + else transientFailures++; continue; } @@ -4207,27 +4423,44 @@ export async function runTokenHealthJob(deps: { // Ownership transfer (spec: Auth flows): full reclaim — main cleared, // demotion unless locked, deprovision jobs enqueued, sessions revoked. // No last-character guard: transfer legitimately empties accounts. - await db.transaction(async (tx) => { - await logAudit(tx, { - actor: "system", - action: "character.owner_mismatch", - target: String(ch.id), - details: { detectedBy: "token-health" }, + // The service re-verifies account+owner under the character lock, so a + // transfer that already completed concurrently is never double-applied. + const result = await db.transaction(async (tx) => { + const r = await reclaimTransferredCharacter(tx, ch.id, { + accountId: ch.accountId, + ownerHash: ch.ownerHash, }); - await reclaimTransferredCharacter(tx, ch.id); + if (r.ok) { + await logAudit(tx, { + actor: "system", + action: "character.owner_mismatch", + target: String(ch.id), + details: { detectedBy: "token-health" }, + }); + } + return r; }); - counts.unlinked++; + if (result.ok) counts.unlinked++; + else transientFailures++; // row changed underneath — next run decides continue; } // Scope shortfall vs the CURRENT required set ⇒ needs_reauth (one-click - // in-place re-auth in the UI); full coverage ⇒ valid. + // in-place re-auth in the UI); full coverage ⇒ valid. Guarded on the + // blob we rotated to — a miss means the row moved on without us. const covered = cfg.eveSso.scopes.every((s) => identity.scopes.includes(s)); const nextStatus = covered ? ("valid" as const) : ("needs_reauth" as const); - await db + const statusRows = await db .update(character) .set({ scopes: identity.scopes, tokenStatus: nextStatus }) - .where(eq(character.id, ch.id)); + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), + ) + .returning({ id: character.id }); + if (statusRows.length === 0) { + transientFailures++; + continue; + } if (nextStatus === "needs_reauth" && ch.tokenStatus !== "needs_reauth") { await logAudit(db, { actor: "system", @@ -4698,8 +4931,8 @@ git commit -m "feat: transactional outbox dispatcher with singleton fan-out" - Consumes: everything from Tasks 6–13; `PgBoss` from `pg-boss`. - Produces: - `createQueues(boss: PgBoss): Promise` — creates the dead-letter queue plus all seven job queues with `{ policy: "short", retryLimit: 5, retryDelay: 60, retryBackoff: true, deadLetter: "ops-dead-letter" }`. **`policy: "short"` is load-bearing:** pg-boss enforces singletonKey uniqueness only via the `job_i1` partial index scoped to that policy — on the default `standard` policy singletonKey coalesces nothing (see Global Constraints). - - `type JobDeps = { db: Db; cfg: Config; esi: Pick & ContactsEsi; wanderer: WandererClient; discord: DiscordClient; fetchImpl?: typeof fetch }` and `buildJobHandlers(deps: JobDeps): Record Promise>` in `src/worker/handlers.ts` — one handler per job queue, each zod-parsing its payload (fail closed) and invoking the job. This is the seam Task 15 uses to drive dispatcher-emitted payloads through the REAL worker routing; `src/worker/index.ts` registers the same handlers with `boss.work`. - - `scheduleJobs(boss: PgBoss): Promise` — cron per spec: membership `*/30 * * * *`; membership-recheck (weekly `affiliation_invalid` recheck) `0 4 * * 0`; contacts `5 * * * *`; wanderer `10 * * * *`; discord-roles `15 * * * *`; token-health `0 3 * * *`; purge `30 3 * * *`. (pg-boss supports ONE schedule per queue — that's why the weekly recheck is its own queue.) + - `type JobDeps = { db: Db; cfg: Config; esi: Pick & ContactsEsi; wanderer: WandererClient; discord: DiscordClient; fetchImpl?: typeof fetch }` and `buildJobHandlers(deps: JobDeps): Record Promise>` in `src/worker/handlers.ts` — one handler per job queue, each parsing its payload with a `.strict()` zod schema requiring the queue's LITERAL `jobType` (truly fail closed — `{ garbage: true }` rejects instead of triggering a global job) and invoking the job. This is the seam Task 15 uses to drive dispatcher-emitted payloads through the REAL worker routing; `src/worker/index.ts` registers the same handlers with `boss.work`. + - `scheduleJobs(boss: PgBoss): Promise` — cron per spec: membership `*/30 * * * *`; membership-recheck (weekly `affiliation_invalid` recheck) `0 4 * * 0`; contacts `5 * * * *`; wanderer `10 * * * *`; discord-roles `15 * * * *`; token-health `0 3 * * *`; purge `30 3 * * *`. (pg-boss supports ONE schedule per queue — that's why the weekly recheck is its own queue.) **Every schedule passes the queue's global singleton key** (`membership:all`, `contacts:all`, `wanderer:all`, `roles:all`, …) so scheduled ticks and dispatcher-emitted global sends coalesce with each other instead of queueing overlapping reconciliations. - `src/worker/index.ts` — the worker container entrypoint: starts pg-boss on `cfg.databaseUrl`, creates queues, registers `boss.work` handlers (zod-parsing job data), registers the dead-letter handler (posts ops webhook naming `data.jobType`), applies schedules, starts the dispatcher, and shuts down cleanly on SIGTERM/SIGINT. - [ ] **Step 1: Write failing test** @@ -4735,17 +4968,24 @@ describe("worker queues", () => { expect(second).toBeNull(); // coalesced }); - it("applies one schedule per queue", async () => { + it("applies one schedule per queue, each carrying its global singleton key", async () => { await scheduleJobs(boss); const schedules = await boss.getSchedules(); - const byName = new Map(schedules.map((s) => [s.name, s.cron])); - expect(byName.get(QUEUES.membership)).toBe("*/30 * * * *"); - expect(byName.get(QUEUES.membershipRecheck)).toBe("0 4 * * 0"); - expect(byName.get(QUEUES.contacts)).toBe("5 * * * *"); - expect(byName.get(QUEUES.wanderer)).toBe("10 * * * *"); - expect(byName.get(QUEUES.discordRoles)).toBe("15 * * * *"); - expect(byName.get(QUEUES.tokenHealth)).toBe("0 3 * * *"); - expect(byName.get(QUEUES.purge)).toBe("30 3 * * *"); + const byName = new Map(schedules.map((s) => [s.name, s])); + expect(byName.get(QUEUES.membership)?.cron).toBe("*/30 * * * *"); + expect(byName.get(QUEUES.membershipRecheck)?.cron).toBe("0 4 * * 0"); + expect(byName.get(QUEUES.contacts)?.cron).toBe("5 * * * *"); + expect(byName.get(QUEUES.wanderer)?.cron).toBe("10 * * * *"); + expect(byName.get(QUEUES.discordRoles)?.cron).toBe("15 * * * *"); + expect(byName.get(QUEUES.tokenHealth)?.cron).toBe("0 3 * * *"); + expect(byName.get(QUEUES.purge)?.cron).toBe("30 3 * * *"); + // scheduled ticks coalesce with dispatcher-emitted global sends + expect(byName.get(QUEUES.contacts)?.options).toMatchObject({ + singletonKey: "contacts:all", + }); + expect(byName.get(QUEUES.wanderer)?.options).toMatchObject({ + singletonKey: "wanderer:all", + }); }); }); ``` @@ -4796,15 +5036,50 @@ export async function createQueues(boss: PgBoss): Promise { * staggered to avoid stampeding shared integrations. */ export async function scheduleJobs(boss: PgBoss): Promise { - await boss.schedule(QUEUES.membership, "*/30 * * * *", { jobType: QUEUES.membership }); - await boss.schedule(QUEUES.membershipRecheck, "0 4 * * 0", { - jobType: QUEUES.membershipRecheck, - }); - await boss.schedule(QUEUES.contacts, "5 * * * *", { jobType: QUEUES.contacts }); - await boss.schedule(QUEUES.wanderer, "10 * * * *", { jobType: QUEUES.wanderer }); - await boss.schedule(QUEUES.discordRoles, "15 * * * *", { jobType: QUEUES.discordRoles }); - await boss.schedule(QUEUES.tokenHealth, "0 3 * * *", { jobType: QUEUES.tokenHealth }); - await boss.schedule(QUEUES.purge, "30 3 * * *", { jobType: QUEUES.purge }); + // Schedules share the dispatcher's global singleton keys so a scheduled + // tick and an on-demand global trigger coalesce instead of double-queueing. + await boss.schedule( + QUEUES.membership, + "*/30 * * * *", + { jobType: QUEUES.membership }, + { singletonKey: "membership:all" }, + ); + await boss.schedule( + QUEUES.membershipRecheck, + "0 4 * * 0", + { jobType: QUEUES.membershipRecheck }, + { singletonKey: "membership-recheck:all" }, + ); + await boss.schedule( + QUEUES.contacts, + "5 * * * *", + { jobType: QUEUES.contacts }, + { singletonKey: "contacts:all" }, + ); + await boss.schedule( + QUEUES.wanderer, + "10 * * * *", + { jobType: QUEUES.wanderer }, + { singletonKey: "wanderer:all" }, + ); + await boss.schedule( + QUEUES.discordRoles, + "15 * * * *", + { jobType: QUEUES.discordRoles }, + { singletonKey: "roles:all" }, + ); + await boss.schedule( + QUEUES.tokenHealth, + "0 3 * * *", + { jobType: QUEUES.tokenHealth }, + { singletonKey: "token-health:all" }, + ); + await boss.schedule( + QUEUES.purge, + "30 3 * * *", + { jobType: QUEUES.purge }, + { singletonKey: "purge:all" }, + ); } ``` @@ -4827,11 +5102,29 @@ import type { EsiClient } from "@/lib/esi/client"; import type { WandererClient } from "@/lib/wanderer/client"; import { QUEUES } from "@/worker/queues"; -const accountScopedSchema = z.object({ accountId: z.string().uuid().optional() }); -const discordJobSchema = z.object({ - accountId: z.string().uuid().optional(), - discordUserId: z.string().optional(), -}); +// Fail closed: every payload must carry the queue's literal jobType and no +// unknown fields — garbage never triggers a job (it rejects, retries, and +// surfaces via the dead-letter alert). +const membershipSchema = z + .object({ + jobType: z.literal(QUEUES.membership), + accountId: z.string().uuid().optional(), + }) + .strict(); +const membershipRecheckSchema = z + .object({ jobType: z.literal(QUEUES.membershipRecheck) }) + .strict(); +const contactsSchema = z.object({ jobType: z.literal(QUEUES.contacts) }).strict(); +const wandererSchema = z.object({ jobType: z.literal(QUEUES.wanderer) }).strict(); +const discordSchema = z + .object({ + jobType: z.literal(QUEUES.discordRoles), + accountId: z.string().uuid().optional(), + discordUserId: z.string().optional(), + }) + .strict(); +const tokenHealthSchema = z.object({ jobType: z.literal(QUEUES.tokenHealth) }).strict(); +const purgeSchema = z.object({ jobType: z.literal(QUEUES.purge) }).strict(); export type JobDeps = { db: Db; @@ -4853,33 +5146,37 @@ export function buildJobHandlers( ): Record Promise> { return { [QUEUES.membership]: async (data) => { - const { accountId } = accountScopedSchema.parse(data); + const { accountId } = membershipSchema.parse(data); await runMembershipJob(deps, { accountId }); }, - [QUEUES.membershipRecheck]: async () => { + [QUEUES.membershipRecheck]: async (data) => { + membershipRecheckSchema.parse(data); await runMembershipJob(deps, { recheckInvalid: true }); }, - [QUEUES.contacts]: async () => { + [QUEUES.contacts]: async (data) => { + contactsSchema.parse(data); await runContactsJob(deps); }, - [QUEUES.wanderer]: async () => { + [QUEUES.wanderer]: async (data) => { + wandererSchema.parse(data); await runWandererJob(deps); }, [QUEUES.discordRoles]: async (data) => { - await runDiscordRolesJob(deps, discordJobSchema.parse(data)); + const { accountId, discordUserId } = discordSchema.parse(data); + await runDiscordRolesJob(deps, { accountId, discordUserId }); }, - [QUEUES.tokenHealth]: async () => { + [QUEUES.tokenHealth]: async (data) => { + tokenHealthSchema.parse(data); await runTokenHealthJob(deps); }, - [QUEUES.purge]: async () => { + [QUEUES.purge]: async (data) => { + purgeSchema.parse(data); await runPurgeJob(deps); }, }; } ``` -(The zod schemas intentionally ignore the extra `jobType` field every payload carries — zod objects strip unknown keys by default.) - `src/worker/index.ts`: ```ts @@ -4990,7 +5287,7 @@ import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; import { auditLog, wandererAclObservation } from "@/db/schema"; import type { DiscordClient } from "@/lib/discord/rest"; import type { Affiliation } from "@/lib/esi/client"; -import type { WandererClient } from "@/lib/wanderer/client"; +import type { WandererAclMember, WandererClient } from "@/lib/wanderer/client"; import { dispatchOutbox } from "@/worker/dispatcher"; import { buildJobHandlers, type JobDeps } from "@/worker/handlers"; import { setupTestDb } from "./helpers/db"; @@ -5057,7 +5354,7 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang }; // Wanderer: the ACL still lists the leaver's chars. - let aclMembers: Array<{ characterId: number | null; role: string }> = [ + let aclMembers: WandererAclMember[] = [ { characterId: 10, role: "viewer" }, { characterId: 11, role: "viewer" }, { characterId: 20, role: "viewer" }, @@ -5067,6 +5364,9 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang addAclMember: async (id) => { aclMembers.push({ characterId: id, role: "viewer" }); }, + updateAclMemberRole: async (id, role) => { + aclMembers = aclMembers.map((m) => (m.characterId === id ? { ...m, role } : m)); + }, removeAclMember: async (id) => { aclMembers = aclMembers.filter((m) => m.characterId !== id); }, @@ -5148,6 +5448,9 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang expect(tierChange?.details).toMatchObject({ to: "green", cause: "main left alliance" }); expect(audits.filter((a) => a.action === "wanderer.removed")).toHaveLength(2); expect(audits.some((a) => a.action === "discord.role_changed")).toBe(true); + + // 8) Fail-closed routing: garbage payloads reject instead of running a job. + await expect(handlers["membership"]({ garbage: true })).rejects.toThrow(); }); ``` From 617a7a903bd4fdda7bf8a39ef32f660e5afbc2a9 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:06:51 -0400 Subject: [PATCH 04/28] feat: fail-closed validation for Discord OAuth responses --- src/lib/discord/oauth.ts | 35 +++++++++++++++++++----- tests/discord-link.test.ts | 4 +-- tests/discord-oauth.test.ts | 53 +++++++++++++++++++++++++++++++++++++ tests/helpers/config.ts | 31 ++++++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 tests/discord-oauth.test.ts create mode 100644 tests/helpers/config.ts diff --git a/src/lib/discord/oauth.ts b/src/lib/discord/oauth.ts index 28958c8e..bab5d1a5 100644 --- a/src/lib/discord/oauth.ts +++ b/src/lib/discord/oauth.ts @@ -1,5 +1,22 @@ +import { z } from "zod"; import type { Config } from "@/config"; +export class DiscordOAuthError extends Error { + status?: number; + constructor(message: string, status?: number) { + super(message); + this.status = status; + } +} + +const tokenResponseSchema = z.object({ access_token: z.string().min(1) }); +// Snowflake ids are decimal digit strings; this value feeds the unique +// discord_user_id identity column, so anything else is rejected outright. +const userResponseSchema = z.object({ + id: z.string().regex(/^\d+$/), + username: z.string().min(1), +}); + export function buildDiscordAuthorizeUrl( cfg: Config, state: string, @@ -35,9 +52,12 @@ export async function exchangeDiscordCode( }).toString(), signal: AbortSignal.timeout(10_000), }); - if (!res.ok) throw new Error(`discord token exchange failed (${res.status})`); - const json = (await res.json()) as { access_token: string }; - return { accessToken: json.access_token }; + if (!res.ok) { + throw new DiscordOAuthError(`discord token exchange failed (${res.status})`, res.status); + } + const parsed = tokenResponseSchema.safeParse(await res.json().catch(() => undefined)); + if (!parsed.success) throw new DiscordOAuthError("discord token response malformed"); + return { accessToken: parsed.data.access_token }; } export async function fetchDiscordUser( @@ -48,7 +68,10 @@ export async function fetchDiscordUser( headers: { authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(10_000), }); - if (!res.ok) throw new Error(`discord user fetch failed (${res.status})`); - const json = (await res.json()) as { id: string; username: string }; - return { id: json.id, username: json.username }; + if (!res.ok) { + throw new DiscordOAuthError(`discord user fetch failed (${res.status})`, res.status); + } + const parsed = userResponseSchema.safeParse(await res.json().catch(() => undefined)); + if (!parsed.success) throw new DiscordOAuthError("discord user response malformed"); + return { id: parsed.data.id, username: parsed.data.username }; } diff --git a/tests/discord-link.test.ts b/tests/discord-link.test.ts index 3a7f3ed4..21082c14 100644 --- a/tests/discord-link.test.ts +++ b/tests/discord-link.test.ts @@ -125,7 +125,7 @@ describe("discord callback route", () => { return HttpResponse.json({ access_token: "dt" }); }), http.get("https://discord.com/api/users/@me", () => - HttpResponse.json({ id: "duid-route", username: "user" }), + HttpResponse.json({ id: "123456789012345678", username: "user" }), ), ); msw.listen({ onUnhandledRequest: "error" }); @@ -144,7 +144,7 @@ describe("discord callback route", () => { const res = await discordCallback(req); expect(res.status).toBe(307); const rows = await ctx.db.select().from(discordLink); - expect(rows[0]?.discordUserId).toBe("duid-route"); + expect(rows[0]?.discordUserId).toBe("123456789012345678"); } finally { msw.close(); } diff --git a/tests/discord-oauth.test.ts b/tests/discord-oauth.test.ts new file mode 100644 index 00000000..1e3a9691 --- /dev/null +++ b/tests/discord-oauth.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { exchangeDiscordCode, fetchDiscordUser } from "@/lib/discord/oauth"; +import { testConfig } from "./helpers/config"; + +const cfg = testConfig(); + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("exchangeDiscordCode", () => { + it("returns the access token", async () => { + const fetchImpl = (async () => jsonResponse({ access_token: "tok" })) as typeof fetch; + expect(await exchangeDiscordCode(cfg, "c", "v", fetchImpl)).toEqual({ + accessToken: "tok", + }); + }); + + it("fails closed on a malformed token response", async () => { + const fetchImpl = (async () => jsonResponse({ nope: true })) as typeof fetch; + await expect(exchangeDiscordCode(cfg, "c", "v", fetchImpl)).rejects.toThrow(/malformed/); + }); + + it("fails closed on an empty access_token", async () => { + const fetchImpl = (async () => jsonResponse({ access_token: "" })) as typeof fetch; + await expect(exchangeDiscordCode(cfg, "c", "v", fetchImpl)).rejects.toThrow(/malformed/); + }); +}); + +describe("fetchDiscordUser", () => { + it("returns id and username", async () => { + const fetchImpl = (async () => + jsonResponse({ id: "123456789", username: "pilot" })) as typeof fetch; + expect(await fetchDiscordUser("at", fetchImpl)).toEqual({ + id: "123456789", + username: "pilot", + }); + }); + + it("rejects a non-snowflake id (feeds a unique identity column)", async () => { + const fetchImpl = (async () => + jsonResponse({ id: "abc", username: "pilot" })) as typeof fetch; + await expect(fetchDiscordUser("at", fetchImpl)).rejects.toThrow(/malformed/); + }); + + it("rejects a non-JSON body", async () => { + const fetchImpl = (async () => + new Response("oops", { status: 200 })) as typeof fetch; + await expect(fetchDiscordUser("at", fetchImpl)).rejects.toThrow(/malformed/); + }); +}); diff --git a/tests/helpers/config.ts b/tests/helpers/config.ts new file mode 100644 index 00000000..10a7a511 --- /dev/null +++ b/tests/helpers/config.ts @@ -0,0 +1,31 @@ +import { loadConfig, type Config } from "@/config"; + +export function testConfig(overrides: Partial = {}): Config { + return loadConfig({ + DATABASE_URL: "postgres://x/y", + TOKEN_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString("base64"), + APP_BASE_URL: "https://auth.example", + ALLIANCE_ID: "99000001", + BOOTSTRAP_ADMIN_CHARACTER_IDS: "", + EVE_SSO_CLIENT_ID: "client-id", + EVE_SSO_CLIENT_SECRET: "client-secret", + EVE_SSO_SCOPES: + "esi-characters.read_contacts.v1 esi-characters.write_contacts.v1", + EVE_SCOPE_SET_VERSION: "1", + DISCORD_CLIENT_ID: "d-cid", + DISCORD_CLIENT_SECRET: "d-sec", + DISCORD_BOT_TOKEN: "bot-token", + DISCORD_GUILD_ID: "9000", + DISCORD_ROLE_ID_FLYGD: "10", + DISCORD_ROLE_ID_BLUE: "11", + DISCORD_ROLE_ID_GREEN: "12", + DISCORD_OPS_WEBHOOK_URL: "https://discord.example/webhook", + WANDERER_BASE_URL: "https://wanderer.example", + WANDERER_API_KEY: "wkey", + WANDERER_MAP_SLUG: "map", + WANDERER_ACL_ID: "acl-1", + STANDINGS_LABEL: "flygd", + STANDINGS_VALUE: "5", + ...overrides, + } as NodeJS.ProcessEnv); +} From 00eda9a03e288d91cb6641e660f925e4d0930d49 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:09:15 -0400 Subject: [PATCH 05/28] feat: sync_run job wrapper and ops webhook --- src/lib/ops-webhook.ts | 22 +++++++++++++ src/services/sync-run.ts | 63 ++++++++++++++++++++++++++++++++++++ tests/ops-webhook.test.ts | 28 ++++++++++++++++ tests/sync-run.test.ts | 68 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 src/lib/ops-webhook.ts create mode 100644 src/services/sync-run.ts create mode 100644 tests/ops-webhook.test.ts create mode 100644 tests/sync-run.test.ts diff --git a/src/lib/ops-webhook.ts b/src/lib/ops-webhook.ts new file mode 100644 index 00000000..21075184 --- /dev/null +++ b/src/lib/ops-webhook.ts @@ -0,0 +1,22 @@ +import type { Config } from "@/config"; + +/** Posts to the optional Discord ops webhook. Never throws — alerting must not break jobs. */ +export async function postOpsWebhook( + cfg: Config, + content: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = cfg.discord.opsWebhookUrl; + if (!url) return; + try { + const res = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: content.slice(0, 1900) }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) console.error(`ops webhook post failed (${res.status})`); + } catch (err) { + console.error("ops webhook post failed", err); + } +} diff --git a/src/services/sync-run.ts b/src/services/sync-run.ts new file mode 100644 index 00000000..6a920867 --- /dev/null +++ b/src/services/sync-run.ts @@ -0,0 +1,63 @@ +import { eq } from "drizzle-orm"; +import type { Db, Dbx } from "@/db"; +import { syncRun } from "@/db/schema"; + +export type JobResult = { + status: "ok" | "partial" | "failed"; + errorSummary?: string; + counts?: Record; + /** When true, runJob throws JobRetryError after recording so pg-boss retries. */ + retry?: boolean; +}; + +export class JobRetryError extends Error {} + +export async function startSyncRun(dbx: Dbx, jobType: string): Promise { + const [row] = await dbx.insert(syncRun).values({ jobType }).returning(); + return row.id; +} + +export async function finishSyncRun( + dbx: Dbx, + id: number, + result: Omit, +): Promise { + await dbx + .update(syncRun) + .set({ + finishedAt: new Date(), + status: result.status, + errorSummary: result.errorSummary ?? null, + counts: result.counts ?? null, + }) + .where(eq(syncRun.id, id)); +} + +/** + * Uniform job wrapper: one sync_run row per execution. Transient trouble is + * reported via result.retry (recorded, then thrown as JobRetryError so pg-boss + * retries the idempotent job); permanent/config failures return status + * "failed" WITHOUT retry so they don't retry-loop. + */ +export async function runJob( + db: Db, + jobType: string, + fn: () => Promise, +): Promise { + const id = await startSyncRun(db, jobType); + let result: JobResult; + try { + result = await fn(); + } catch (err) { + await finishSyncRun(db, id, { + status: "failed", + errorSummary: err instanceof Error ? err.message : String(err), + }); + throw err; + } + await finishSyncRun(db, id, result); + if (result.retry) { + throw new JobRetryError(`${jobType}: ${result.errorSummary ?? "transient failures"}`); + } + return result; +} diff --git a/tests/ops-webhook.test.ts b/tests/ops-webhook.test.ts new file mode 100644 index 00000000..5c8024e5 --- /dev/null +++ b/tests/ops-webhook.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { postOpsWebhook } from "@/lib/ops-webhook"; +import { testConfig } from "./helpers/config"; + +describe("postOpsWebhook", () => { + it("posts content to the configured webhook", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 204 })); + await postOpsWebhook(testConfig(), "job failed", fetchImpl as unknown as typeof fetch); + expect(fetchImpl).toHaveBeenCalledOnce(); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://discord.example/webhook"); + expect(JSON.parse(init.body as string)).toEqual({ content: "job failed" }); + }); + + it("is a no-op when no webhook is configured", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 204 })); + const cfg = testConfig({ DISCORD_OPS_WEBHOOK_URL: "" }); + await postOpsWebhook(cfg, "x", fetchImpl as unknown as typeof fetch); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("never throws, even when the post fails", async () => { + const fetchImpl = (async () => { + throw new Error("network down"); + }) as typeof fetch; + await expect(postOpsWebhook(testConfig(), "x", fetchImpl)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/sync-run.test.ts b/tests/sync-run.test.ts new file mode 100644 index 00000000..510de9ef --- /dev/null +++ b/tests/sync-run.test.ts @@ -0,0 +1,68 @@ +import { desc } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { syncRun } from "@/db/schema"; +import { JobRetryError, runJob } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); + +async function latestRun() { + const rows = await ctx.db + .select() + .from(syncRun) + .orderBy(desc(syncRun.id)) + .limit(1); + return rows[0]; +} + +describe("runJob", () => { + it("records an ok run with counts", async () => { + await runJob(ctx.db, "membership", async () => ({ + status: "ok", + counts: { resolved: 3 }, + })); + const run = await latestRun(); + expect(run.jobType).toBe("membership"); + expect(run.status).toBe("ok"); + expect(run.finishedAt).not.toBeNull(); + expect(run.counts).toEqual({ resolved: 3 }); + }); + + it("records failed and rethrows on unexpected errors", async () => { + await expect( + runJob(ctx.db, "contacts", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + const run = await latestRun(); + expect(run.status).toBe("failed"); + expect(run.errorSummary).toContain("boom"); + }); + + it("records the result then throws JobRetryError when retry is requested", async () => { + await expect( + runJob(ctx.db, "wanderer", async () => ({ + status: "partial", + errorSummary: "2 transient failures", + retry: true, + })), + ).rejects.toBeInstanceOf(JobRetryError); + const run = await latestRun(); + expect(run.status).toBe("partial"); + expect(run.errorSummary).toBe("2 transient failures"); + }); + + it("records failed WITHOUT throwing for permanent-config results", async () => { + const result = await runJob(ctx.db, "discord-roles", async () => ({ + status: "failed", + errorSummary: "managed role ids are not distinct", + })); + expect(result.status).toBe("failed"); + const run = await latestRun(); + expect(run.status).toBe("failed"); + }); +}); From 000d0f68aa0697b5a5e1a5fc14f596026ffe19ac Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:12:50 -0400 Subject: [PATCH 06/28] feat: throttled ESI client with fail-closed parsing --- src/core/chunk.ts | 5 + src/lib/esi/client.ts | 229 +++++++++++++++++++++++++++++++++++++++ tests/esi-client.test.ts | 200 ++++++++++++++++++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 src/core/chunk.ts create mode 100644 src/lib/esi/client.ts create mode 100644 tests/esi-client.test.ts diff --git a/src/core/chunk.ts b/src/core/chunk.ts new file mode 100644 index 00000000..5a5c4693 --- /dev/null +++ b/src/core/chunk.ts @@ -0,0 +1,5 @@ +export function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} diff --git a/src/lib/esi/client.ts b/src/lib/esi/client.ts new file mode 100644 index 00000000..3e6391bd --- /dev/null +++ b/src/lib/esi/client.ts @@ -0,0 +1,229 @@ +import { z } from "zod"; +import { chunk } from "@/core/chunk"; +import { classifyEsiError, type EsiErrorClass } from "@/core/errors"; + +const ESI_BASE = "https://esi.evetech.net/latest"; +const WRITE_CHUNK = 100; // ESI POST/PUT contacts body limit +const DELETE_CHUNK = 20; // ESI DELETE contacts query limit +const AFFILIATION_MAX = 500; + +export class EsiError extends Error { + status: number; + kind: EsiErrorClass; + constructor(message: string, status: number, kind: EsiErrorClass) { + super(message); + this.status = status; + this.kind = kind; + } +} + +const affiliationSchema = z.array( + z.object({ + character_id: z.number().int(), + corporation_id: z.number().int(), + alliance_id: z.number().int().optional(), + }), +); +const labelsSchema = z.array( + z.object({ label_id: z.number().int(), label_name: z.string() }), +); +const contactsSchema = z.array( + z.object({ + contact_id: z.number().int(), + contact_type: z.string(), + standing: z.number(), + label_ids: z.array(z.number().int()).nullish(), + }), +); + +export type Affiliation = { + characterId: number; + corporationId: number; + allianceId: number | null; +}; +export type EsiContact = { + contactId: number; + contactType: string; + standing: number; + labelIds: number[]; +}; + +export interface EsiClientOptions { + fetchImpl?: typeof fetch; + now?: () => number; + sleep?: (ms: number) => Promise; + /** Pause when the remaining ESI error budget is at or below this. */ + errorBudgetFloor?: number; +} + +export function createEsiClient(opts: EsiClientOptions = {}) { + const fetchImpl = opts.fetchImpl ?? fetch; + const now = opts.now ?? Date.now; + const sleep = + opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const floor = opts.errorBudgetFloor ?? 5; + + // ESI etiquette: honor X-ESI-Error-Limit-Remain/Reset across all calls. + let remain = Number.POSITIVE_INFINITY; + let resetAt = 0; // epoch ms + + async function request( + path: string, + init: RequestInit & { accessToken?: string } = {}, + ): Promise { + if (remain <= floor && resetAt > now()) { + await sleep(resetAt - now()); + remain = Number.POSITIVE_INFINITY; + } + const headers: Record = { + accept: "application/json", + ...(init.headers as Record | undefined), + }; + if (init.accessToken) headers.authorization = `Bearer ${init.accessToken}`; + const res = await fetchImpl(`${ESI_BASE}${path}`, { + ...init, + headers, + signal: AbortSignal.timeout(30_000), + }); + const remainHeader = res.headers.get("x-esi-error-limit-remain"); + const resetHeader = res.headers.get("x-esi-error-limit-reset"); + if (remainHeader !== null) remain = Number(remainHeader); + if (resetHeader !== null) resetAt = now() + Number(resetHeader) * 1000; + if (!res.ok) { + const body = (await res.json().catch(() => undefined)) as + | { error?: string } + | undefined; + throw new EsiError( + `ESI ${init.method ?? "GET"} ${path} failed (${res.status}${body?.error ? `: ${body.error}` : ""})`, + res.status, + classifyEsiError(res.status, body), + ); + } + return res; + } + + async function postAffiliation(ids: number[]): Promise { + if (ids.length === 0) return []; + if (ids.length > AFFILIATION_MAX) { + throw new Error(`postAffiliation: max ${AFFILIATION_MAX} ids per call`); + } + const res = await request("/characters/affiliation/", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(ids), + }); + return affiliationSchema.parse(await res.json()).map((a) => ({ + characterId: a.character_id, + corporationId: a.corporation_id, + allianceId: a.alliance_id ?? null, + })); + } + + async function getContactLabels( + characterId: number, + accessToken: string, + ): Promise> { + const res = await request(`/characters/${characterId}/contacts/labels/`, { + accessToken, + }); + return labelsSchema + .parse(await res.json()) + .map((l) => ({ labelId: l.label_id, labelName: l.label_name })); + } + + /** Reads ALL pages; any page failure rejects the whole call. */ + async function getAllContacts( + characterId: number, + accessToken: string, + ): Promise { + const first = await request(`/characters/${characterId}/contacts/?page=1`, { + accessToken, + }); + // Fail closed: an unknown page count means an unknown contact set, and the + // downstream diff deletes. Never guess (spec: never remove on unknown state). + const pagesHeader = first.headers.get("x-pages"); + const pages = Number(pagesHeader); + if (pagesHeader === null || !Number.isInteger(pages) || pages < 1) { + throw new EsiError( + `ESI GET contacts: missing or invalid X-Pages header (${pagesHeader})`, + 0, + "transient", + ); + } + const raw = contactsSchema.parse(await first.json()).slice(); + for (let page = 2; page <= pages; page++) { + const res = await request( + `/characters/${characterId}/contacts/?page=${page}`, + { accessToken }, + ); + raw.push(...contactsSchema.parse(await res.json())); + } + return raw.map((c) => ({ + contactId: c.contact_id, + contactType: c.contact_type, + standing: c.standing, + labelIds: c.label_ids ?? [], + })); + } + + function contactWriteParams(standing: number, labelIds: number[]): string { + const params = new URLSearchParams({ standing: String(standing) }); + for (const l of labelIds) params.append("label_ids", String(l)); + return params.toString(); + } + + async function writeContacts( + method: "POST" | "PUT", + characterId: number, + accessToken: string, + contactIds: number[], + standing: number, + labelIds: number[], + ): Promise { + for (const ids of chunk(contactIds, WRITE_CHUNK)) { + await request( + `/characters/${characterId}/contacts/?${contactWriteParams(standing, labelIds)}`, + { + method, + accessToken, + headers: { "content-type": "application/json" }, + body: JSON.stringify(ids), + }, + ); + } + } + + return { + postAffiliation, + getContactLabels, + getAllContacts, + addContacts: ( + characterId: number, + accessToken: string, + contactIds: number[], + standing: number, + labelIds: number[], + ) => writeContacts("POST", characterId, accessToken, contactIds, standing, labelIds), + editContacts: ( + characterId: number, + accessToken: string, + contactIds: number[], + standing: number, + labelIds: number[], + ) => writeContacts("PUT", characterId, accessToken, contactIds, standing, labelIds), + deleteContacts: async ( + characterId: number, + accessToken: string, + contactIds: number[], + ): Promise => { + for (const ids of chunk(contactIds, DELETE_CHUNK)) { + await request( + `/characters/${characterId}/contacts/?contact_ids=${ids.join(",")}`, + { method: "DELETE", accessToken }, + ); + } + }, + }; +} + +export type EsiClient = ReturnType; diff --git a/tests/esi-client.test.ts b/tests/esi-client.test.ts new file mode 100644 index 00000000..a586ad25 --- /dev/null +++ b/tests/esi-client.test.ts @@ -0,0 +1,200 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createEsiClient, EsiError } from "@/lib/esi/client"; +import { chunk } from "@/core/chunk"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const BASE = "https://esi.evetech.net/latest"; + +describe("chunk", () => { + it("splits into fixed-size chunks", () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + expect(chunk([], 3)).toEqual([]); + }); +}); + +describe("postAffiliation", () => { + it("maps fields and defaults missing alliance to null", async () => { + server.use( + http.post(`${BASE}/characters/affiliation/`, async ({ request }) => { + expect(await request.json()).toEqual([1, 2]); + return HttpResponse.json([ + { character_id: 1, corporation_id: 100, alliance_id: 99000001 }, + { character_id: 2, corporation_id: 200 }, + ]); + }), + ); + const esi = createEsiClient(); + expect(await esi.postAffiliation([1, 2])).toEqual([ + { characterId: 1, corporationId: 100, allianceId: 99000001 }, + { characterId: 2, corporationId: 200, allianceId: null }, + ]); + }); + + it("rejects more than 500 ids", async () => { + const esi = createEsiClient(); + await expect( + esi.postAffiliation(Array.from({ length: 501 }, (_, i) => i + 1)), + ).rejects.toThrow(/500/); + }); + + it("throws a classified EsiError on failure", async () => { + server.use( + http.post(`${BASE}/characters/affiliation/`, () => + HttpResponse.json({ error: "rate limited" }, { status: 420 }), + ), + ); + const esi = createEsiClient(); + const err = await esi.postAffiliation([1]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(EsiError); + expect((err as EsiError).status).toBe(420); + expect((err as EsiError).kind).toBe("transient"); + }); + + it("fails closed on a malformed body", async () => { + server.use( + http.post(`${BASE}/characters/affiliation/`, () => + HttpResponse.json([{ character_id: "not-a-number" }]), + ), + ); + const esi = createEsiClient(); + await expect(esi.postAffiliation([1])).rejects.toThrow(); + }); +}); + +describe("error-limit throttling", () => { + it("pauses until reset when the error budget is low", async () => { + let calls = 0; + server.use( + http.post(`${BASE}/characters/affiliation/`, () => { + calls++; + return HttpResponse.json([], { + headers: { + "X-ESI-Error-Limit-Remain": "3", + "X-ESI-Error-Limit-Reset": "42", + }, + }); + }), + ); + const sleeps: number[] = []; + let nowMs = 1_000_000; + const esi = createEsiClient({ + now: () => nowMs, + sleep: async (ms) => { + sleeps.push(ms); + nowMs += ms; + }, + }); + await esi.postAffiliation([1]); // response says remain=3 (≤ floor of 5) + await esi.postAffiliation([2]); // must pause until reset first + expect(calls).toBe(2); + expect(sleeps).toEqual([42_000]); + }); +}); + +describe("contacts", () => { + it("reads all pages before returning", async () => { + const pages: Record = { + "1": [{ contact_id: 11, contact_type: "character", standing: 5, label_ids: [7] }], + "2": [{ contact_id: 12, contact_type: "character", standing: 0 }], + }; + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, ({ request }) => { + const page = new URL(request.url).searchParams.get("page") ?? "1"; + return HttpResponse.json(pages[page], { headers: { "X-Pages": "2" } }); + }), + ); + const esi = createEsiClient(); + expect(await esi.getAllContacts(90000001, "at")).toEqual([ + { contactId: 11, contactType: "character", standing: 5, labelIds: [7] }, + { contactId: 12, contactType: "character", standing: 0, labelIds: [] }, + ]); + }); + + it("fails closed on a missing or malformed X-Pages header", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([]), // no X-Pages header at all + ), + ); + const esi = createEsiClient(); + await expect(esi.getAllContacts(90000001, "at")).rejects.toThrow(/X-Pages/); + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([], { headers: { "X-Pages": "abc" } }), + ), + ); + await expect(esi.getAllContacts(90000001, "at")).rejects.toThrow(/X-Pages/); + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([], { headers: { "X-Pages": "0" } }), + ), + ); + await expect(esi.getAllContacts(90000001, "at")).rejects.toThrow(/X-Pages/); + }); + + it("rejects the whole read when any page fails", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, ({ request }) => { + const page = new URL(request.url).searchParams.get("page") ?? "1"; + if (page === "2") return HttpResponse.json({ error: "boom" }, { status: 500 }); + return HttpResponse.json( + [{ contact_id: 11, contact_type: "character", standing: 5 }], + { headers: { "X-Pages": "2" } }, + ); + }), + ); + const esi = createEsiClient(); + await expect(esi.getAllContacts(90000001, "at")).rejects.toBeInstanceOf(EsiError); + }); + + it("sends the bearer token and label/standing params on writes, chunked at 100", async () => { + const bodies: number[][] = []; + server.use( + http.post(`${BASE}/characters/90000001/contacts/`, async ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bearer at"); + const url = new URL(request.url); + expect(url.searchParams.get("standing")).toBe("5"); + expect(url.searchParams.getAll("label_ids")).toEqual(["7"]); + bodies.push((await request.json()) as number[]); + return HttpResponse.json([], { status: 201 }); + }), + ); + const esi = createEsiClient(); + const ids = Array.from({ length: 150 }, (_, i) => i + 1); + await esi.addContacts(90000001, "at", ids, 5, [7]); + expect(bodies.map((b) => b.length)).toEqual([100, 50]); + }); + + it("chunks deletes at 20 via query params", async () => { + const deletes: string[] = []; + server.use( + http.delete(`${BASE}/characters/90000001/contacts/`, ({ request }) => { + deletes.push(new URL(request.url).searchParams.get("contact_ids") ?? ""); + return HttpResponse.json([]); + }), + ); + const esi = createEsiClient(); + await esi.deleteContacts(90000001, "at", Array.from({ length: 45 }, (_, i) => i + 1)); + expect(deletes).toHaveLength(3); + expect(deletes[0].split(",")).toHaveLength(20); + expect(deletes[2].split(",")).toHaveLength(5); + }); + + it("parses contact labels", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/labels/`, () => + HttpResponse.json([{ label_id: 7, label_name: "flygd" }]), + ), + ); + const esi = createEsiClient(); + expect(await esi.getContactLabels(90000001, "at")).toEqual([ + { labelId: 7, labelName: "flygd" }, + ]); + }); +}); From 37c486025b5062050806071d02e316d1ddee1fdf Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:17:10 -0400 Subject: [PATCH 07/28] fix: classify malformed ESI bodies as permanent EsiError --- src/lib/esi/client.ts | 54 +++++++++++++++++++++++++++++++++++----- tests/esi-client.test.ts | 18 +++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/lib/esi/client.ts b/src/lib/esi/client.ts index 3e6391bd..84c91c39 100644 --- a/src/lib/esi/client.ts +++ b/src/lib/esi/client.ts @@ -67,6 +67,24 @@ export function createEsiClient(opts: EsiClientOptions = {}) { let remain = Number.POSITIVE_INFINITY; let resetAt = 0; // epoch ms + function safeParse( + schema: z.ZodSchema, + data: unknown, + method: string, + path: string, + status: number, + ): T { + try { + return schema.parse(data); + } catch { + throw new EsiError( + `ESI ${method} ${path}: malformed response body`, + status, + "permanent", + ); + } + } + async function request( path: string, init: RequestInit & { accessToken?: string } = {}, @@ -112,7 +130,13 @@ export function createEsiClient(opts: EsiClientOptions = {}) { headers: { "content-type": "application/json" }, body: JSON.stringify(ids), }); - return affiliationSchema.parse(await res.json()).map((a) => ({ + return safeParse( + affiliationSchema, + await res.json(), + "POST", + "/characters/affiliation/", + res.status, + ).map((a) => ({ characterId: a.character_id, corporationId: a.corporation_id, allianceId: a.alliance_id ?? null, @@ -126,9 +150,13 @@ export function createEsiClient(opts: EsiClientOptions = {}) { const res = await request(`/characters/${characterId}/contacts/labels/`, { accessToken, }); - return labelsSchema - .parse(await res.json()) - .map((l) => ({ labelId: l.label_id, labelName: l.label_name })); + return safeParse( + labelsSchema, + await res.json(), + "GET", + `/characters/${characterId}/contacts/labels/`, + res.status, + ).map((l) => ({ labelId: l.label_id, labelName: l.label_name })); } /** Reads ALL pages; any page failure rejects the whole call. */ @@ -150,13 +178,27 @@ export function createEsiClient(opts: EsiClientOptions = {}) { "transient", ); } - const raw = contactsSchema.parse(await first.json()).slice(); + const raw = safeParse( + contactsSchema, + await first.json(), + "GET", + `/characters/${characterId}/contacts/?page=1`, + first.status, + ).slice(); for (let page = 2; page <= pages; page++) { const res = await request( `/characters/${characterId}/contacts/?page=${page}`, { accessToken }, ); - raw.push(...contactsSchema.parse(await res.json())); + raw.push( + ...safeParse( + contactsSchema, + await res.json(), + "GET", + `/characters/${characterId}/contacts/?page=${page}`, + res.status, + ), + ); } return raw.map((c) => ({ contactId: c.contact_id, diff --git a/tests/esi-client.test.ts b/tests/esi-client.test.ts index a586ad25..d0ab04eb 100644 --- a/tests/esi-client.test.ts +++ b/tests/esi-client.test.ts @@ -63,7 +63,9 @@ describe("postAffiliation", () => { ), ); const esi = createEsiClient(); - await expect(esi.postAffiliation([1])).rejects.toThrow(); + const err = await esi.postAffiliation([1]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(EsiError); + expect((err as EsiError).kind).toBe("permanent"); }); }); @@ -153,6 +155,20 @@ describe("contacts", () => { await expect(esi.getAllContacts(90000001, "at")).rejects.toBeInstanceOf(EsiError); }); + it("fails closed on malformed contact data", async () => { + server.use( + http.get(`${BASE}/characters/90000001/contacts/`, () => + HttpResponse.json([{ contact_id: "not-a-number", contact_type: "character" }], { + headers: { "X-Pages": "1" }, + }), + ), + ); + const esi = createEsiClient(); + const err = await esi.getAllContacts(90000001, "at").catch((e: unknown) => e); + expect(err).toBeInstanceOf(EsiError); + expect((err as EsiError).kind).toBe("permanent"); + }); + it("sends the bearer token and label/standing params on writes, chunked at 100", async () => { const bodies: number[][] = []; server.use( From 2c2cf4d9fc0e3b0ba6355d9fb3a89c32840d950b Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:19:32 -0400 Subject: [PATCH 08/28] feat: affiliation chunk/bisect resolution and tier decision rule --- src/core/affiliation.ts | 57 ++++++++++++++++++++++++++++++++++ src/core/tier.ts | 19 ++++++++++++ tests/affiliation.test.ts | 65 +++++++++++++++++++++++++++++++++++++++ tests/tier.test.ts | 36 ++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/core/affiliation.ts create mode 100644 src/core/tier.ts create mode 100644 tests/affiliation.test.ts create mode 100644 tests/tier.test.ts diff --git a/src/core/affiliation.ts b/src/core/affiliation.ts new file mode 100644 index 00000000..d4966e1a --- /dev/null +++ b/src/core/affiliation.ts @@ -0,0 +1,57 @@ +import { chunk } from "@/core/chunk"; +import { EsiError, type Affiliation } from "@/lib/esi/client"; + +export type AffiliationOutcome = { + resolved: Map; + /** Deterministic 400 on a single id — safe to flag affiliation_invalid. */ + invalid: number[]; + /** Transient or ambiguous failures — never flagged, retried next run. */ + unresolved: number[]; +}; + +const CHUNK_SIZE = 500; + +export async function resolveAffiliations( + ids: number[], + post: (ids: number[]) => Promise, +): Promise { + const out: AffiliationOutcome = { resolved: new Map(), invalid: [], unresolved: [] }; + for (const batch of chunk(ids, CHUNK_SIZE)) { + await resolveChunk(batch, post, out); + } + return out; +} + +async function resolveChunk( + ids: number[], + post: (ids: number[]) => Promise, + out: AffiliationOutcome, +): Promise { + if (ids.length === 0) return; + try { + const rows = await post(ids); + const returned = new Set(); + for (const r of rows) { + returned.add(r.characterId); + out.resolved.set(r.characterId, { + corporationId: r.corporationId, + allianceId: r.allianceId, + }); + } + for (const id of ids) if (!returned.has(id)) out.unresolved.push(id); + } catch (err) { + // Bisect ONLY deterministic invalid-request responses. Anything else + // (420/5xx/network, or odd permanent statuses) must never flag characters. + if (err instanceof EsiError && err.status === 400) { + if (ids.length === 1) { + out.invalid.push(ids[0]); + return; + } + const mid = Math.ceil(ids.length / 2); + await resolveChunk(ids.slice(0, mid), post, out); + await resolveChunk(ids.slice(mid), post, out); + return; + } + out.unresolved.push(...ids); + } +} diff --git a/src/core/tier.ts b/src/core/tier.ts new file mode 100644 index 00000000..0c5ad016 --- /dev/null +++ b/src/core/tier.ts @@ -0,0 +1,19 @@ +export type Tier = "flygd" | "blue" | "green"; + +/** + * Membership rule: unlocked accounts are system-managed — the desired tier is + * flygd when the main is in the configured alliance, green otherwise (this is + * how an unlocked Blue converges after "return to auto"). Transitions require + * a CONFIRMED affiliation read of the main in this run. Returns the tier to + * set, or null for no change. + */ +export function decideTier(input: { + tier: Tier; + tierLocked: boolean; + mainConfirmed: boolean; + mainInAlliance: boolean; +}): "flygd" | "green" | null { + if (input.tierLocked || !input.mainConfirmed) return null; + const desired = input.mainInAlliance ? "flygd" : "green"; + return input.tier === desired ? null : desired; +} diff --git a/tests/affiliation.test.ts b/tests/affiliation.test.ts new file mode 100644 index 00000000..dacd794d --- /dev/null +++ b/tests/affiliation.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveAffiliations } from "@/core/affiliation"; +import { EsiError, type Affiliation } from "@/lib/esi/client"; + +const okFor = (ids: number[]): Affiliation[] => + ids.map((id) => ({ characterId: id, corporationId: id * 10, allianceId: 99000001 })); + +describe("resolveAffiliations", () => { + it("resolves a clean batch", async () => { + const out = await resolveAffiliations([1, 2, 3], async (ids) => okFor(ids)); + expect(out.resolved.size).toBe(3); + expect(out.resolved.get(2)).toEqual({ corporationId: 20, allianceId: 99000001 }); + expect(out.invalid).toEqual([]); + expect(out.unresolved).toEqual([]); + }); + + it("submits in chunks of at most 500", async () => { + const sizes: number[] = []; + const ids = Array.from({ length: 1100 }, (_, i) => i + 1); + await resolveAffiliations(ids, async (batch) => { + sizes.push(batch.length); + return okFor(batch); + }); + expect(sizes).toEqual([500, 500, 100]); + }); + + it("bisects deterministic 400s down to the bad ids only", async () => { + const bad = new Set([2, 5]); + const post = vi.fn(async (ids: number[]): Promise => { + if (ids.some((id) => bad.has(id))) { + throw new EsiError("bad id", 400, "permanent"); + } + return okFor(ids); + }); + const out = await resolveAffiliations([1, 2, 3, 4, 5, 6], post); + expect([...out.invalid].sort((a, b) => a - b)).toEqual([2, 5]); + expect([...out.resolved.keys()].sort((a, b) => a - b)).toEqual([1, 3, 4, 6]); + expect(out.unresolved).toEqual([]); + }); + + it("NEVER bisects or flags on transient failures", async () => { + const post = vi.fn(async (): Promise => { + throw new EsiError("rate limited", 420, "transient"); + }); + const out = await resolveAffiliations([1, 2, 3], post); + expect(out.invalid).toEqual([]); + expect([...out.unresolved].sort((a, b) => a - b)).toEqual([1, 2, 3]); + expect(post).toHaveBeenCalledTimes(1); // no bisection attempts + }); + + it("treats non-400 permanent errors as unresolved, not invalid", async () => { + const post = async (): Promise => { + throw new EsiError("not found", 404, "permanent"); + }; + const out = await resolveAffiliations([1, 2], post); + expect(out.invalid).toEqual([]); + expect(out.unresolved).toEqual([1, 2]); + }); + + it("marks ids omitted from a successful response as unresolved", async () => { + const out = await resolveAffiliations([1, 2], async () => okFor([1])); + expect([...out.resolved.keys()]).toEqual([1]); + expect(out.unresolved).toEqual([2]); + }); +}); diff --git a/tests/tier.test.ts b/tests/tier.test.ts new file mode 100644 index 00000000..913a9283 --- /dev/null +++ b/tests/tier.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { decideTier } from "@/core/tier"; + +describe("decideTier", () => { + const cases: Array<{ + name: string; + tier: "flygd" | "blue" | "green"; + tierLocked: boolean; + mainConfirmed: boolean; + mainInAlliance: boolean; + expected: "flygd" | "green" | null; + }> = [ + { name: "green + main in alliance → flygd", tier: "green", tierLocked: false, mainConfirmed: true, mainInAlliance: true, expected: "flygd" }, + { name: "flygd + main left alliance → green", tier: "flygd", tierLocked: false, mainConfirmed: true, mainInAlliance: false, expected: "green" }, + { name: "flygd + main in alliance → no change", tier: "flygd", tierLocked: false, mainConfirmed: true, mainInAlliance: true, expected: null }, + { name: "green + main out → no change", tier: "green", tierLocked: false, mainConfirmed: true, mainInAlliance: false, expected: null }, + { name: "unlocked blue converges to flygd", tier: "blue", tierLocked: false, mainConfirmed: true, mainInAlliance: true, expected: "flygd" }, + { name: "unlocked blue converges to green", tier: "blue", tierLocked: false, mainConfirmed: true, mainInAlliance: false, expected: "green" }, + { name: "locked accounts are never touched", tier: "flygd", tierLocked: true, mainConfirmed: true, mainInAlliance: false, expected: null }, + { name: "locked blue stays blue", tier: "blue", tierLocked: true, mainConfirmed: true, mainInAlliance: true, expected: null }, + { name: "unconfirmed main is never transitioned", tier: "flygd", tierLocked: false, mainConfirmed: false, mainInAlliance: false, expected: null }, + { name: "unconfirmed main never promotes either", tier: "green", tierLocked: false, mainConfirmed: false, mainInAlliance: true, expected: null }, + ]; + for (const c of cases) { + it(c.name, () => { + expect( + decideTier({ + tier: c.tier, + tierLocked: c.tierLocked, + mainConfirmed: c.mainConfirmed, + mainInAlliance: c.mainInAlliance, + }), + ).toBe(c.expected); + }); + } +}); From 6dbd62523573742ef7bf947ba361be821107aca2 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:25:47 -0400 Subject: [PATCH 09/28] feat: token refresh service with permanent/transient classification --- src/services/tokens.ts | 116 ++++++++++++++++++++++++++++++++++++ tests/helpers/seed.ts | 69 ++++++++++++++++++++++ tests/tokens.test.ts | 129 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 src/services/tokens.ts create mode 100644 tests/helpers/seed.ts create mode 100644 tests/tokens.test.ts diff --git a/src/services/tokens.ts b/src/services/tokens.ts new file mode 100644 index 00000000..5a0ce78d --- /dev/null +++ b/src/services/tokens.ts @@ -0,0 +1,116 @@ +import { and, eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character } from "@/db/schema"; +import { classifyOAuthError } from "@/core/errors"; +import { decryptToken, encryptToken } from "@/lib/crypto"; +import { EveSsoError, refreshEveToken } from "@/lib/esi/sso"; +import { logAudit } from "@/services/audit"; + +export type CharacterTokenRow = { + id: number; + refreshTokenEnc: string | null; + tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; +}; + +export type AccessTokenResult = + | { ok: true; accessToken: string; tokenEnc: string } + | { ok: false; reason: "no_token" | "invalid" | "transient"; detail?: string }; + +/** + * Marks the token invalid ONLY if the stored blob is still the one this + * decision was based on — one conditional transaction, auditing only when the + * guard wins. A miss means the row changed underneath us (rotation, re-auth, + * or transfer reclaim): the stale decision is discarded. + */ +async function invalidateIfUnchanged( + db: Db, + characterId: number, + expectedEnc: string, + reason: string, +): Promise { + return db.transaction(async (tx) => { + const rows = await tx + .update(character) + .set({ tokenStatus: "invalid" }) + .where( + and(eq(character.id, characterId), eq(character.refreshTokenEnc, expectedEnc)), + ) + .returning({ id: character.id }); + if (rows.length === 0) return false; + await logAudit(tx, { + actor: "system", + action: "token.invalidated", + target: String(characterId), + details: { reason }, + }); + return true; + }); +} + +/** + * Refreshes the character's token and persists the rotated refresh token. + * Permanent OAuth failures — and malformed stored blobs — mark token_status + * invalid; transient failures change no state (spec: Error handling). + */ +export async function getFreshAccessToken( + db: Db, + cfg: Config, + ch: CharacterTokenRow, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!ch.refreshTokenEnc || ch.tokenStatus === "invalid" || ch.tokenStatus === "missing") { + return { ok: false, reason: "no_token" }; + } + let refreshToken: string; + try { + refreshToken = decryptToken(ch.refreshTokenEnc, cfg.tokenEncryptionKey); + } catch { + const applied = await invalidateIfUnchanged(db, ch.id, ch.refreshTokenEnc, "malformed_token_blob"); + return applied + ? { ok: false, reason: "invalid", detail: "malformed_token_blob" } + : { ok: false, reason: "transient", detail: "concurrent rotation" }; + } + try { + const r = await refreshEveToken(cfg, refreshToken, fetchImpl); + // Compare-and-swap on the blob we read: EVE rotates refresh tokens on + // every use, so a concurrent job (or a transfer reclaim) may have won the + // row first. A miss means our whole read is stale — report transient and + // let the next run work from fresh state; never hand out the stale token. + const tokenEnc = encryptToken(r.refreshToken, cfg.tokenEncryptionKey); + const rows = await db + .update(character) + .set({ refreshTokenEnc: tokenEnc }) + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, ch.refreshTokenEnc)), + ) + .returning({ id: character.id }); + if (rows.length === 0) { + return { ok: false, reason: "transient", detail: "concurrent rotation" }; + } + return { ok: true, accessToken: r.accessToken, tokenEnc }; + } catch (err) { + if ( + err instanceof EveSsoError && + classifyOAuthError(err.oauthError, err.status) === "permanent" + ) { + // invalid_grant on the OLD blob says nothing about a token another job + // rotated in the meantime — the conditional update discards the stale + // decision atomically (no separate read-then-write window). + const applied = await invalidateIfUnchanged( + db, + ch.id, + ch.refreshTokenEnc, + err.oauthError ?? `status_${err.status}`, + ); + return applied + ? { ok: false, reason: "invalid", detail: err.oauthError } + : { ok: false, reason: "transient", detail: "concurrent rotation" }; + } + return { + ok: false, + reason: "transient", + detail: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/tests/helpers/seed.ts b/tests/helpers/seed.ts new file mode 100644 index 00000000..e571c9f8 --- /dev/null +++ b/tests/helpers/seed.ts @@ -0,0 +1,69 @@ +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { account, character, discordLink } from "@/db/schema"; +import { encryptToken } from "@/lib/crypto"; + +export async function seedAccount( + db: Db, + opts: { + tier?: "flygd" | "blue" | "green"; + tierLocked?: boolean; + discordUserId?: string; + } = {}, +) { + const [acc] = await db + .insert(account) + .values({ tier: opts.tier ?? "green", tierLocked: opts.tierLocked ?? false }) + .returning(); + if (opts.discordUserId) { + await db + .insert(discordLink) + .values({ accountId: acc.id, discordUserId: opts.discordUserId }); + } + return acc; +} + +export async function seedCharacter( + db: Db, + cfg: Config, + opts: { + id: number; + accountId: string; + name?: string; + ownerHash?: string; + /** null → no stored token; otherwise encrypted with the test key. */ + refreshToken?: string | null; + scopes?: string[]; + tokenStatus?: "valid" | "invalid" | "needs_reauth" | "missing"; + /** Also set as the account's main character. */ + main?: boolean; + allianceId?: number | null; + affiliationInvalid?: boolean; + }, +) { + const [ch] = await db + .insert(character) + .values({ + id: opts.id, + accountId: opts.accountId, + name: opts.name ?? `Char ${opts.id}`, + ownerHash: opts.ownerHash ?? `oh-${opts.id}`, + refreshTokenEnc: + opts.refreshToken === null + ? null + : encryptToken(opts.refreshToken ?? "refresh", cfg.tokenEncryptionKey), + scopes: opts.scopes ?? [...cfg.eveSso.scopes], + tokenStatus: opts.tokenStatus ?? "valid", + allianceId: opts.allianceId ?? null, + affiliationInvalid: opts.affiliationInvalid ?? false, + }) + .returning(); + if (opts.main) { + await db + .update(account) + .set({ mainCharacterId: opts.id }) + .where(eq(account.id, opts.accountId)); + } + return ch; +} diff --git a/tests/tokens.test.ts b/tests/tokens.test.ts new file mode 100644 index 00000000..1c83d13c --- /dev/null +++ b/tests/tokens.test.ts @@ -0,0 +1,129 @@ +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { auditLog, character } from "@/db/schema"; +import { decryptToken, encryptToken } from "@/lib/crypto"; +import { getFreshAccessToken } from "@/services/tokens"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const tokenJson = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +async function seed(opts: Partial[2]> = {}) { + const acc = await seedAccount(ctx.db); + return seedCharacter(ctx.db, cfg, { id: 90000001, accountId: acc.id, ...opts }); +} + +async function getChar(id: number) { + const rows = await ctx.db.select().from(character).where(eq(character.id, id)); + return rows[0]; +} + +describe("getFreshAccessToken", () => { + it("returns the access token + stored blob and persists the rotated refresh token", async () => { + const ch = await seed({ refreshToken: "old-rt" }); + const fetchImpl = (async () => + tokenJson({ access_token: "new-at", refresh_token: "new-rt" })) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); + expect(r).toMatchObject({ ok: true, accessToken: "new-at" }); + const updated = await getChar(90000001); + expect(decryptToken(updated.refreshTokenEnc as string, cfg.tokenEncryptionKey)).toBe("new-rt"); + // tokenEnc is exactly what is now stored — callers guard follow-up writes on it + expect(r).toMatchObject({ tokenEnc: updated.refreshTokenEnc }); + }); + + it("marks token invalid + audits on permanent OAuth errors", async () => { + const ch = await seed({}); + const fetchImpl = (async () => + tokenJson({ error: "invalid_grant" }, 400)) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); + expect(r).toMatchObject({ ok: false, reason: "invalid" }); + expect((await getChar(90000001)).tokenStatus).toBe("invalid"); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.invalidated")).toBe(true); + }); + + it("changes NO state on transient errors", async () => { + const ch = await seed({}); + const fetchImpl = (async () => + tokenJson({ error: "temporarily_unavailable" }, 503)) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, ch, fetchImpl); + expect(r).toMatchObject({ ok: false, reason: "transient" }); + expect((await getChar(90000001)).tokenStatus).toBe("valid"); + }); + + it("maps a malformed stored blob to a clean invalid (carry-over)", async () => { + const ch = await seed({}); + await ctx.db + .update(character) + .set({ refreshTokenEnc: "not.a.blob" }) + .where(eq(character.id, 90000001)); + const r = await getFreshAccessToken( + ctx.db, + cfg, + { ...ch, refreshTokenEnc: "not.a.blob" }, + (async () => tokenJson({})) as typeof fetch, + ); + expect(r).toMatchObject({ ok: false, reason: "invalid", detail: "malformed_token_blob" }); + expect((await getChar(90000001)).tokenStatus).toBe("invalid"); + }); + + it("returns no_token for missing or already-invalid tokens", async () => { + const ch = await seed({ refreshToken: null, tokenStatus: "missing" }); + const r = await getFreshAccessToken(ctx.db, cfg, ch, (async () => + tokenJson({})) as typeof fetch); + expect(r).toEqual({ ok: false, reason: "no_token" }); + }); + + it("treats a CAS miss on success as TRANSIENT — a stale token is never usable", async () => { + const stale = await seed({ refreshToken: "old-rt" }); // row as WE read it + // another job rotates underneath us before our refresh completes + const currentBlob = encryptToken("current-rt", cfg.tokenEncryptionKey); + await ctx.db + .update(character) + .set({ refreshTokenEnc: currentBlob }) + .where(eq(character.id, 90000001)); + const fetchImpl = (async () => + tokenJson({ access_token: "our-at", refresh_token: "our-rt" })) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, stale, fetchImpl); + // the row changed hands under us — our whole read is stale, so downstream + // callers must NOT act on this character this run + expect(r).toMatchObject({ ok: false, reason: "transient" }); + const after = await getChar(90000001); + // the first writer's stored refresh token wins + expect(decryptToken(after.refreshTokenEnc as string, cfg.tokenEncryptionKey)).toBe("current-rt"); + }); + + it("skips invalidation when the blob rotated during a failed refresh", async () => { + const stale = await seed({ refreshToken: "old-rt" }); + await ctx.db + .update(character) + .set({ refreshTokenEnc: encryptToken("current-rt", cfg.tokenEncryptionKey) }) + .where(eq(character.id, 90000001)); + // invalid_grant for the OLD token proves nothing about the NEW one + const fetchImpl = (async () => + tokenJson({ error: "invalid_grant" }, 400)) as typeof fetch; + const r = await getFreshAccessToken(ctx.db, cfg, stale, fetchImpl); + expect(r).toMatchObject({ ok: false, reason: "transient" }); + expect((await getChar(90000001)).tokenStatus).toBe("valid"); // NOT invalidated + }); +}); From 63533f6fec9e3796cdeea77a57d340215f08e1e2 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:39:48 -0400 Subject: [PATCH 10/28] feat: membership verification job with confirmed-read tier transitions --- src/jobs/membership.ts | 144 +++++++++++++++++++++++++++++++++ tests/membership-job.test.ts | 149 +++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/jobs/membership.ts create mode 100644 tests/membership-job.test.ts diff --git a/src/jobs/membership.ts b/src/jobs/membership.ts new file mode 100644 index 00000000..263c77a9 --- /dev/null +++ b/src/jobs/membership.ts @@ -0,0 +1,144 @@ +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { account, character } from "@/db/schema"; +import { resolveAffiliations } from "@/core/affiliation"; +import { decideTier } from "@/core/tier"; +import type { EsiClient } from "@/lib/esi/client"; +import { logAudit } from "@/services/audit"; +import { enqueueSync } from "@/services/outbox"; +import { runJob, type JobResult } from "@/services/sync-run"; + +export async function runMembershipJob( + deps: { db: Db; cfg: Config; esi: Pick }, + opts: { accountId?: string; recheckInvalid?: boolean } = {}, +): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "membership", async () => { + const chars = await db + .select({ + id: character.id, + accountId: character.accountId, + affiliationInvalid: character.affiliationInvalid, + }) + .from(character) + .where(opts.accountId ? eq(character.accountId, opts.accountId) : undefined); + // affiliation_invalid ids are excluded from batches; the weekly recheck + // (and the admin recheck button) pass recheckInvalid to include them. + const eligible = chars.filter((c) => opts.recheckInvalid || !c.affiliationInvalid); + const outcome = await resolveAffiliations( + eligible.map((c) => c.id), + (ids) => esi.postAffiliation(ids), + ); + + const checkedAt = new Date(); + for (const [id, aff] of outcome.resolved) { + await db + .update(character) + .set({ + corporationId: aff.corporationId, + allianceId: aff.allianceId, + affiliationCheckedAt: checkedAt, + affiliationInvalid: false, + }) + .where(eq(character.id, id)); + } + if (outcome.invalid.length > 0) { + const alreadyFlagged = new Set( + chars.filter((c) => c.affiliationInvalid).map((c) => c.id), + ); + await db + .update(character) + .set({ affiliationInvalid: true, affiliationCheckedAt: checkedAt }) + .where(inArray(character.id, outcome.invalid)); + for (const id of outcome.invalid.filter((i) => !alreadyFlagged.has(i))) { + await logAudit(db, { + actor: "system", + action: "character.affiliation_invalid", + target: String(id), + }); + } + } + + // Tier pass: skip locked and null-main accounts; transition only on a + // confirmed read of the MAIN in this run (an ESI outage can never demote). + const accounts = await db + .select() + .from(account) + .where( + and( + opts.accountId ? eq(account.id, opts.accountId) : undefined, + eq(account.tierLocked, false), + isNotNull(account.mainCharacterId), + ), + ); + let promoted = 0; + let demoted = 0; + for (const acc of accounts) { + const mainAff = + acc.mainCharacterId === null + ? undefined + : outcome.resolved.get(acc.mainCharacterId); + const next = decideTier({ + tier: acc.tier, + tierLocked: acc.tierLocked, + mainConfirmed: mainAff !== undefined, + mainInAlliance: mainAff?.allianceId === cfg.allianceId, + }); + if (!next) continue; + // State change + downstream job trigger commit in ONE transaction. + const applied = await db.transaction(async (tx) => { + const [locked] = await tx + .select() + .from(account) + .where(eq(account.id, acc.id)) + .for("update"); + if ( + !locked || + locked.tierLocked || + locked.tier === next || + locked.mainCharacterId !== acc.mainCharacterId + ) { + return false; // changed underneath us — leave it to the next run + } + await tx + .update(account) + .set({ tier: next, tierChangedAt: new Date(), tierChangedBy: "system" }) + .where(eq(account.id, acc.id)); + await logAudit(tx, { + actor: "system", + action: "tier.changed", + target: acc.id, + details: { + from: locked.tier, + to: next, + cause: next === "flygd" ? "main joined alliance" : "main left alliance", + }, + }); + await enqueueSync(tx, { kind: "account", accountId: acc.id }); + return true; + }); + if (!applied) continue; + if (next === "flygd") promoted++; + else demoted++; + } + + const counts = { + checked: eligible.length, + resolved: outcome.resolved.size, + invalid: outcome.invalid.length, + unresolved: outcome.unresolved.length, + promoted, + demoted, + }; + if (outcome.unresolved.length > 0) { + return { + status: "partial", + errorSummary: `${outcome.unresolved.length} characters unresolved (transient)`, + counts, + retry: true, + }; + } + return { status: "ok", counts }; + }); +} diff --git a/tests/membership-job.test.ts b/tests/membership-job.test.ts new file mode 100644 index 00000000..b9af0cc1 --- /dev/null +++ b/tests/membership-job.test.ts @@ -0,0 +1,149 @@ +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { account, auditLog, character, outbox } from "@/db/schema"; +import { runMembershipJob } from "@/jobs/membership"; +import { EsiError, type Affiliation } from "@/lib/esi/client"; +import { JobRetryError } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); // allianceId 99000001 + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +/** ESI fake: every id resolves into the given alliance (or none). */ +const esiWith = (alliances: Record) => ({ + postAffiliation: async (ids: number[]): Promise => + ids.map((id) => ({ + characterId: id, + corporationId: 1000, + allianceId: alliances[id] ?? null, + })), +}); + +async function getAccount(id: string) { + const rows = await ctx.db.select().from(account).where(eq(account.id, id)); + return rows[0]; +} + +describe("runMembershipJob", () => { + it("promotes green → flygd on a confirmed main in alliance, transactionally with the outbox row", async () => { + const acc = await seedAccount(ctx.db, { tier: "green" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const result = await runMembershipJob( + { db: ctx.db, cfg, esi: esiWith({ 1: 99000001 }) }, + ); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ promoted: 1, demoted: 0 }); + const after = await getAccount(acc.id); + expect(after.tier).toBe("flygd"); + expect(after.tierChangedBy).toBe("system"); + const outboxRows = await ctx.db.select().from(outbox); + expect(outboxRows.map((r) => r.payload)).toContainEqual({ + kind: "account", + accountId: acc.id, + }); + const audits = await ctx.db.select().from(auditLog); + expect( + audits.some((a) => a.action === "tier.changed" && a.target === acc.id), + ).toBe(true); + }); + + it("demotes flygd → green when the main left the alliance", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id, main: true }); + await runMembershipJob({ db: ctx.db, cfg, esi: esiWith({ 2: null }) }); + expect((await getAccount(acc.id)).tier).toBe("green"); + }); + + it("never touches tier_locked accounts", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd", tierLocked: true }); + await seedCharacter(ctx.db, cfg, { id: 3, accountId: acc.id, main: true }); + await runMembershipJob({ db: ctx.db, cfg, esi: esiWith({ 3: null }) }); + expect((await getAccount(acc.id)).tier).toBe("flygd"); + }); + + it("skips null-main accounts", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 4, accountId: acc.id }); // not main + await runMembershipJob({ db: ctx.db, cfg, esi: esiWith({ 4: null }) }); + expect((await getAccount(acc.id)).tier).toBe("flygd"); + }); + + it("leaves accounts with unresolved mains untouched and retries", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 5, accountId: acc.id, main: true }); + const esi = { + postAffiliation: async (): Promise => { + throw new EsiError("esi down", 503, "transient"); + }, + }; + await expect( + runMembershipJob({ db: ctx.db, cfg, esi }), + ).rejects.toBeInstanceOf(JobRetryError); + expect((await getAccount(acc.id)).tier).toBe("flygd"); // an ESI outage can never mass-demote + }); + + it("flags only bisected 400 ids as affiliation_invalid and audits them", async () => { + const acc = await seedAccount(ctx.db, { tier: "green" }); + await seedCharacter(ctx.db, cfg, { id: 6, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 7, accountId: acc.id }); + const esi = { + postAffiliation: async (ids: number[]): Promise => { + if (ids.includes(7)) throw new EsiError("bad id", 400, "permanent"); + return ids.map((id) => ({ characterId: id, corporationId: 1, allianceId: 99000001 })); + }, + }; + const result = await runMembershipJob({ db: ctx.db, cfg, esi }); + expect(result.counts).toMatchObject({ invalid: 1, promoted: 1 }); + const rows = await ctx.db.select().from(character).where(eq(character.id, 7)); + expect(rows[0].affiliationInvalid).toBe(true); + const six = await ctx.db.select().from(character).where(eq(character.id, 6)); + expect(six[0].affiliationInvalid).toBe(false); + expect(six[0].allianceId).toBe(99000001); + }); + + it("excludes flagged characters unless recheckInvalid is set", async () => { + const acc = await seedAccount(ctx.db); + await seedCharacter(ctx.db, cfg, { id: 8, accountId: acc.id, affiliationInvalid: true }); + const seen: number[][] = []; + const esi = { + postAffiliation: async (ids: number[]): Promise => { + seen.push(ids); + return ids.map((id) => ({ characterId: id, corporationId: 1, allianceId: null })); + }, + }; + await runMembershipJob({ db: ctx.db, cfg, esi }); + expect(seen.flat()).not.toContain(8); + await runMembershipJob({ db: ctx.db, cfg, esi }, { recheckInvalid: true }); + expect(seen.flat()).toContain(8); + // a successful recheck clears the flag + const rows = await ctx.db.select().from(character).where(eq(character.id, 8)); + expect(rows[0].affiliationInvalid).toBe(false); + }); + + it("scopes to one account when accountId is passed", async () => { + const a1 = await seedAccount(ctx.db, { tier: "green" }); + const a2 = await seedAccount(ctx.db, { tier: "green" }); + await seedCharacter(ctx.db, cfg, { id: 10, accountId: a1.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 11, accountId: a2.id, main: true }); + await runMembershipJob( + { db: ctx.db, cfg, esi: esiWith({ 10: 99000001, 11: 99000001 }) }, + { accountId: a1.id }, + ); + expect((await getAccount(a1.id)).tier).toBe("flygd"); + expect((await getAccount(a2.id)).tier).toBe("green"); // untouched + }); +}); From 0d4c6a935ff089fa08c0f5005e855f12aab40f88 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:47:16 -0400 Subject: [PATCH 11/28] feat: desired-set query and label-scoped contacts diff --- src/core/contacts-diff.ts | 50 ++++++++++++++++++++++++++ src/services/desired.ts | 31 ++++++++++++++++ tests/contacts-diff.test.ts | 70 +++++++++++++++++++++++++++++++++++++ tests/desired.test.ts | 36 +++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 src/core/contacts-diff.ts create mode 100644 src/services/desired.ts create mode 100644 tests/contacts-diff.test.ts create mode 100644 tests/desired.test.ts diff --git a/src/core/contacts-diff.ts b/src/core/contacts-diff.ts new file mode 100644 index 00000000..1e636e80 --- /dev/null +++ b/src/core/contacts-diff.ts @@ -0,0 +1,50 @@ +export type ContactState = { + contactId: number; + standing: number; + labelIds: number[]; +}; + +export type ContactsDiff = { + add: number[]; + update: Array<{ contactId: number; labelIds: number[] }>; + remove: number[]; +}; + +/** + * Label-ownership policy (accepted-destructive, aa-standingssync precedent): + * the app owns `labelId` outright. Desired ids are added, or taken over if + * they already exist as personal contacts (standing re-asserted, our label + * added while PRESERVING existing labels — ESI PUT replaces label_ids + * wholesale). Contacts carrying our label that leave the desired set are + * deleted entirely. Contacts never carrying our label are never modified. + * `desiredIds` must already exclude the target character itself. + */ +export function diffContacts(input: { + desiredIds: number[]; + standing: number; + labelId: number; + contacts: ContactState[]; +}): ContactsDiff { + const desired = new Set(input.desiredIds); + const byId = new Map(input.contacts.map((c) => [c.contactId, c])); + const add: number[] = []; + const update: Array<{ contactId: number; labelIds: number[] }> = []; + for (const id of input.desiredIds) { + const existing = byId.get(id); + if (!existing) { + add.push(id); + continue; + } + const hasLabel = existing.labelIds.includes(input.labelId); + if (!hasLabel || existing.standing !== input.standing) { + update.push({ + contactId: id, + labelIds: hasLabel ? existing.labelIds : [...existing.labelIds, input.labelId], + }); + } + } + const remove = input.contacts + .filter((c) => c.labelIds.includes(input.labelId) && !desired.has(c.contactId)) + .map((c) => c.contactId); + return { add, update, remove }; +} diff --git a/src/services/desired.ts b/src/services/desired.ts new file mode 100644 index 00000000..6b42a5c8 --- /dev/null +++ b/src/services/desired.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; +import type { Dbx } from "@/db"; +import { account, character } from "@/db/schema"; + +export type FlygdCharacter = { + characterId: number; + accountId: string; + name: string; + refreshTokenEnc: string | null; + tokenStatus: "valid" | "invalid" | "needs_reauth" | "missing"; + scopes: string[]; +}; + +/** + * The derived desired set: every character of every FlyGD account (spec: Data + * model → Derived). Green/Blue accounts simply fall out; nothing is deleted. + */ +export async function getFlygdCharacters(dbx: Dbx): Promise { + return dbx + .select({ + characterId: character.id, + accountId: character.accountId, + name: character.name, + refreshTokenEnc: character.refreshTokenEnc, + tokenStatus: character.tokenStatus, + scopes: character.scopes, + }) + .from(character) + .innerJoin(account, eq(character.accountId, account.id)) + .where(eq(account.tier, "flygd")); +} diff --git a/tests/contacts-diff.test.ts b/tests/contacts-diff.test.ts new file mode 100644 index 00000000..41132d54 --- /dev/null +++ b/tests/contacts-diff.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { diffContacts, type ContactState } from "@/core/contacts-diff"; + +const LABEL = 7; + +const contact = ( + contactId: number, + standing: number, + labelIds: number[] = [], +): ContactState => ({ contactId, standing, labelIds }); + +describe("diffContacts", () => { + it("adds desired ids that are absent", () => { + const d = diffContacts({ desiredIds: [1, 2], standing: 5, labelId: LABEL, contacts: [] }); + expect(d).toEqual({ add: [1, 2], update: [], remove: [] }); + }); + + it("leaves correct labeled contacts alone", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [contact(1, 5, [LABEL])], + }); + expect(d).toEqual({ add: [], update: [], remove: [] }); + }); + + it("takes over an existing personal contact, preserving its labels", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [contact(1, 0, [3])], + }); + expect(d).toEqual({ + add: [], + update: [{ contactId: 1, labelIds: [3, LABEL] }], + remove: [], + }); + }); + + it("re-asserts standing on labeled contacts without duplicating the label", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [contact(1, -10, [LABEL])], + }); + expect(d).toEqual({ + add: [], + update: [{ contactId: 1, labelIds: [LABEL] }], + remove: [], + }); + }); + + it("removes only OUR labeled contacts that left the desired set", () => { + const d = diffContacts({ + desiredIds: [1], + standing: 5, + labelId: LABEL, + contacts: [ + contact(1, 5, [LABEL]), + contact(2, 5, [LABEL]), // ours, no longer desired → delete + contact(3, 10, []), // personal, unlabeled → NEVER touched + contact(4, -5, [9]), // personal, other label → NEVER touched + ], + }); + expect(d).toEqual({ add: [], update: [], remove: [2] }); + }); +}); diff --git a/tests/desired.test.ts b/tests/desired.test.ts new file mode 100644 index 00000000..b27bd36a --- /dev/null +++ b/tests/desired.test.ts @@ -0,0 +1,36 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { sql } from "drizzle-orm"; +import { getFlygdCharacters } from "@/services/desired"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +describe("getFlygdCharacters", () => { + it("returns every character of every flygd account and nothing else", async () => { + const flygd = await seedAccount(ctx.db, { tier: "flygd" }); + const green = await seedAccount(ctx.db, { tier: "green" }); + const blue = await seedAccount(ctx.db, { tier: "blue", tierLocked: true }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: flygd.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: flygd.id }); // alt counts too + await seedCharacter(ctx.db, cfg, { id: 3, accountId: green.id }); + await seedCharacter(ctx.db, cfg, { id: 4, accountId: blue.id }); + const rows = await getFlygdCharacters(ctx.db); + expect(rows.map((r) => r.characterId).sort((a, b) => a - b)).toEqual([1, 2]); + expect(rows[0]).toMatchObject({ accountId: flygd.id, tokenStatus: "valid" }); + }); +}); From 7e990bdeba935a3ac5f028e6bc6be4f41775c35f Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:50:45 -0400 Subject: [PATCH 12/28] feat: per-character contact push with label ownership and abort-on-partial-read --- src/jobs/contacts.ts | 184 ++++++++++++++++++++++++++++++ tests/contacts-job.test.ts | 222 +++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 src/jobs/contacts.ts create mode 100644 tests/contacts-job.test.ts diff --git a/src/jobs/contacts.ts b/src/jobs/contacts.ts new file mode 100644 index 00000000..b2df1b46 --- /dev/null +++ b/src/jobs/contacts.ts @@ -0,0 +1,184 @@ +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db, Dbx } from "@/db"; +import { character, contactSyncState } from "@/db/schema"; +import { diffContacts } from "@/core/contacts-diff"; +import { EsiError, type EsiClient } from "@/lib/esi/client"; +import { getFlygdCharacters, type FlygdCharacter } from "@/services/desired"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +export const CONTACT_SCOPES = [ + "esi-characters.read_contacts.v1", + "esi-characters.write_contacts.v1", +] as const; + +/** + * Per-job scope gate (spec: needs_reauth is a capability warning, never a + * global blocker): a token missing some unrelated scope still pushes contacts + * as long as BOTH contact scopes are granted and the token isn't dead. + */ +export function canPushContacts( + ch: Pick, +): boolean { + if (!ch.refreshTokenEnc) return false; + if (ch.tokenStatus === "invalid" || ch.tokenStatus === "missing") return false; + return CONTACT_SCOPES.every((s) => ch.scopes.includes(s)); +} + +export type ContactsEsi = Pick< + EsiClient, + "getContactLabels" | "getAllContacts" | "addContacts" | "editContacts" | "deleteContacts" +>; + +async function recordResult( + dbx: Dbx, + characterId: number, + result: string, + synced: boolean, +): Promise { + const set = synced + ? { lastResult: result, lastSyncedAt: new Date() } + : { lastResult: result }; + await dbx + .insert(contactSyncState) + .values({ characterId, ...set }) + .onConflictDoUpdate({ target: contactSyncState.characterId, set }); +} + +export async function runContactsJob(deps: { + db: Db; + cfg: Config; + esi: ContactsEsi; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg, esi } = deps; + return runJob(db, "contacts", async () => { + const flygd = await getFlygdCharacters(db); + const desiredAll = flygd.map((c) => c.characterId); + const counts = { targets: 0, added: 0, updated: 0, removed: 0, skipped: 0, failed: 0 }; + let transientFailures = 0; + const errors: string[] = []; + + for (const target of flygd) { + if (!canPushContacts(target)) { + counts.skipped++; + // Persist WHY, so the member/admin pages can show remediation. + const deadToken = + !target.refreshTokenEnc || + target.tokenStatus === "invalid" || + target.tokenStatus === "missing"; + await recordResult( + db, + target.characterId, + deadToken ? "token_invalid" : "missing_scope", + false, + ); + continue; + } + counts.targets++; + const token = await getFreshAccessToken( + db, + cfg, + { + id: target.characterId, + refreshTokenEnc: target.refreshTokenEnc, + tokenStatus: target.tokenStatus, + }, + deps.fetchImpl, + ); + if (!token.ok) { + if (token.reason === "transient") { + transientFailures++; + await recordResult(db, target.characterId, "token_refresh_failed", false); + } else { + counts.failed++; + await recordResult(db, target.characterId, "token_invalid", false); + } + continue; + } + try { + // Labels first: ESI cannot create labels, so a missing label is a + // user-remediation state — record it and skip ALL writes (spec job 2). + const labels = await esi.getContactLabels(target.characterId, token.accessToken); + const label = labels.find((l) => l.labelName === cfg.standings.label); + if (!label) { + counts.skipped++; + await recordResult(db, target.characterId, "missing_label", false); + continue; + } + // Read ALL pages before any destructive diff; getAllContacts rejects + // on any page failure, aborting this character's reconciliation. + const contacts = await esi.getAllContacts(target.characterId, token.accessToken); + const diff = diffContacts({ + desiredIds: desiredAll.filter((id) => id !== target.characterId), + standing: cfg.standings.value, + labelId: label.labelId, + contacts, + }); + if (diff.add.length > 0) { + await esi.addContacts( + target.characterId, + token.accessToken, + diff.add, + cfg.standings.value, + [label.labelId], + ); + } + // Group takeovers by their preserved label set — PUT replaces + // label_ids wholesale, so each distinct union is its own call. + const groups = new Map(); + for (const u of diff.update) { + const key = u.labelIds.join(","); + const g = groups.get(key) ?? { labelIds: u.labelIds, ids: [] }; + g.ids.push(u.contactId); + groups.set(key, g); + } + for (const g of groups.values()) { + await esi.editContacts( + target.characterId, + token.accessToken, + g.ids, + cfg.standings.value, + g.labelIds, + ); + } + if (diff.remove.length > 0) { + await esi.deleteContacts(target.characterId, token.accessToken, diff.remove); + } + counts.added += diff.add.length; + counts.updated += diff.update.length; + counts.removed += diff.remove.length; + await recordResult(db, target.characterId, "ok", true); + } catch (err) { + const needsReauth = err instanceof EsiError && err.kind === "needs_reauth"; + const transient = err instanceof EsiError ? err.kind === "transient" : true; + if (needsReauth) { + counts.failed++; + await db + .update(character) + .set({ tokenStatus: "needs_reauth" }) + .where(eq(character.id, target.characterId)); + await recordResult(db, target.characterId, "needs_reauth", false); + } else { + if (transient) transientFailures++; + else counts.failed++; + await recordResult(db, target.characterId, "sync_failed", false); + } + errors.push( + `${target.characterId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (transientFailures > 0 || counts.failed > 0) { + return { + status: "partial", + errorSummary: errors.slice(0, 5).join("; ") || "token failures", + counts, + retry: transientFailures > 0, + }; + } + return { status: "ok", counts }; + }); +} diff --git a/tests/contacts-job.test.ts b/tests/contacts-job.test.ts new file mode 100644 index 00000000..68addc40 --- /dev/null +++ b/tests/contacts-job.test.ts @@ -0,0 +1,222 @@ +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { character, contactSyncState } from "@/db/schema"; +import { canPushContacts, runContactsJob, type ContactsEsi } from "@/jobs/contacts"; +import { EsiError, type EsiContact } from "@/lib/esi/client"; +import { JobRetryError } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); // label "flygd", standing 5 +const LABEL_ID = 77; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const okToken = (async () => + new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt2" }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + +type Calls = { + adds: Array<{ characterId: number; ids: number[]; labelIds: number[] }>; + edits: Array<{ characterId: number; ids: number[]; labelIds: number[] }>; + deletes: Array<{ characterId: number; ids: number[] }>; +}; + +/** Fake ESI: per-character labels and contacts; records all writes. */ +function fakeEsi(perChar: { + labels?: Record>; + contacts?: Record; +}): { esi: ContactsEsi; calls: Calls } { + const calls: Calls = { adds: [], edits: [], deletes: [] }; + const esi: ContactsEsi = { + getContactLabels: async (characterId) => + perChar.labels?.[characterId] ?? [{ labelId: LABEL_ID, labelName: "flygd" }], + getAllContacts: async (characterId) => { + const c = perChar.contacts?.[characterId] ?? []; + if (c === "fail") throw new EsiError("page read failed", 500, "transient"); + return c; + }, + addContacts: async (characterId, _at, ids, _standing, labelIds) => { + calls.adds.push({ characterId, ids, labelIds }); + }, + editContacts: async (characterId, _at, ids, _standing, labelIds) => { + calls.edits.push({ characterId, ids, labelIds }); + }, + deleteContacts: async (characterId, _at, ids) => { + calls.deletes.push({ characterId, ids }); + }, + }; + return { esi, calls }; +} + +async function lastResult(characterId: number) { + const rows = await ctx.db + .select() + .from(contactSyncState) + .where(eq(contactSyncState.characterId, characterId)); + return rows[0]; +} + +const labeled = (contactId: number, standing = 5): EsiContact => ({ + contactId, + contactType: "character", + standing, + labelIds: [LABEL_ID], +}); + +describe("canPushContacts", () => { + const base = { + refreshTokenEnc: "enc", + tokenStatus: "valid" as const, + scopes: [...cfg.eveSso.scopes], + }; + it("gates on token presence, status, and BOTH contact scopes", () => { + expect(canPushContacts(base)).toBe(true); + expect(canPushContacts({ ...base, refreshTokenEnc: null })).toBe(false); + expect(canPushContacts({ ...base, tokenStatus: "invalid" })).toBe(false); + expect(canPushContacts({ ...base, tokenStatus: "missing" })).toBe(false); + expect( + canPushContacts({ ...base, scopes: ["esi-characters.read_contacts.v1"] }), + ).toBe(false); + }); + it("needs_reauth with contact scopes granted is NOT a blocker", () => { + expect(canPushContacts({ ...base, tokenStatus: "needs_reauth" })).toBe(true); + }); +}); + +describe("runContactsJob", () => { + it("fully reconciles: add, take over, remove ours, never touch unlabeled", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const acc2 = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 3, accountId: acc2.id, main: true }); + // Only character 1 has interesting contacts; keep the others empty. + const { esi, calls } = fakeEsi({ + contacts: { + 1: [ + labeled(3), // desired, correct → untouched + labeled(99), // ours, no longer desired → delete + { contactId: 2, contactType: "character", standing: 0, labelIds: [5] }, // desired, personal → take over + { contactId: 500, contactType: "character", standing: 10, labelIds: [] }, // unlabeled → never touched + ], + }, + }); + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("ok"); + // character 1's desired set excludes itself: {2, 3} + expect(calls.edits).toContainEqual({ characterId: 1, ids: [2], labelIds: [5, LABEL_ID] }); + expect(calls.deletes).toContainEqual({ characterId: 1, ids: [99] }); + expect(calls.adds.filter((c) => c.characterId === 1)).toEqual([]); + // characters 2 and 3 each get the other two added + expect(calls.adds).toContainEqual({ characterId: 2, ids: [1, 3], labelIds: [LABEL_ID] }); + expect(calls.adds).toContainEqual({ characterId: 3, ids: [1, 2], labelIds: [LABEL_ID] }); + expect((await lastResult(1))?.lastResult).toBe("ok"); + expect((await lastResult(1))?.lastSyncedAt).not.toBeNull(); + }); + + it("records missing_label and skips ALL writes for that character", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({ + labels: { 1: [{ labelId: 9, labelName: "other" }] }, + }); + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("ok"); // missing_label is a recorded skip, not a failure + expect((await lastResult(1))?.lastResult).toBe("missing_label"); + expect(calls.adds.filter((c) => c.characterId === 1)).toEqual([]); + expect(calls.deletes.filter((c) => c.characterId === 1)).toEqual([]); + }); + + it("aborts a character on a partial contact read — no destructive writes", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({ contacts: { 1: "fail" } }); + await expect( + runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }), + ).rejects.toBeInstanceOf(JobRetryError); // transient → retry + expect((await lastResult(1))?.lastResult).toBe("sync_failed"); + expect(calls.deletes.filter((c) => c.characterId === 1)).toEqual([]); + // the other character still synced (partial-failure isolation) + expect((await lastResult(2))?.lastResult).toBe("ok"); + }); + + it("skips non-pushable characters but keeps them in the desired set", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id, tokenStatus: "invalid" }); + const { esi, calls } = fakeEsi({}); + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("ok"); + // 2 is not pushed to… + expect(calls.adds.filter((c) => c.characterId === 2)).toEqual([]); + // …but 2 is still in 1's desired set + expect(calls.adds).toContainEqual({ characterId: 1, ids: [2], labelIds: [LABEL_ID] }); + // and the skip reason is persisted for the UI + expect((await lastResult(2))?.lastResult).toBe("token_invalid"); + }); + + it("records missing_scope for targets lacking the contact scopes", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { + id: 2, + accountId: acc.id, + scopes: ["esi-characters.read_contacts.v1"], // write scope missing + tokenStatus: "needs_reauth", + }); + const { esi } = fakeEsi({}); + await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect((await lastResult(2))?.lastResult).toBe("missing_scope"); + }); + + it("needs_reauth with contact scopes still syncs (per-job gating)", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, + accountId: acc.id, + main: true, + tokenStatus: "needs_reauth", // e.g. missing an unrelated new scope + }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({}); + await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(calls.adds).toContainEqual({ characterId: 1, ids: [2], labelIds: [LABEL_ID] }); + }); + + it("marks the character needs_reauth when ESI rejects the scope", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const esi: ContactsEsi = { + ...fakeEsi({}).esi, + getContactLabels: async (characterId) => { + if (characterId === 1) { + throw new EsiError("token has no scope", 403, "needs_reauth"); + } + return [{ labelId: LABEL_ID, labelName: "flygd" }]; + }, + }; + const result = await runContactsJob({ db: ctx.db, cfg, esi, fetchImpl: okToken }); + expect(result.status).toBe("partial"); + expect((await lastResult(1))?.lastResult).toBe("needs_reauth"); + const rows = await ctx.db.select().from(character).where(eq(character.id, 1)); + expect(rows[0].tokenStatus).toBe("needs_reauth"); + }); +}); From 285710da6c66512bd603341e1ff1af0ba2f3474d Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:55:42 -0400 Subject: [PATCH 13/28] feat: wanderer ACL sync with post-mutation observation --- src/core/acl-diff.ts | 24 +++++ src/jobs/wanderer.ts | 126 +++++++++++++++++++++++ src/lib/wanderer/client.ts | 121 ++++++++++++++++++++++ tests/acl-diff.test.ts | 50 ++++++++++ tests/wanderer-client.test.ts | 135 +++++++++++++++++++++++++ tests/wanderer-job.test.ts | 183 ++++++++++++++++++++++++++++++++++ 6 files changed, 639 insertions(+) create mode 100644 src/core/acl-diff.ts create mode 100644 src/jobs/wanderer.ts create mode 100644 src/lib/wanderer/client.ts create mode 100644 tests/acl-diff.test.ts create mode 100644 tests/wanderer-client.test.ts create mode 100644 tests/wanderer-job.test.ts diff --git a/src/core/acl-diff.ts b/src/core/acl-diff.ts new file mode 100644 index 00000000..2895ad72 --- /dev/null +++ b/src/core/acl-diff.ts @@ -0,0 +1,24 @@ +export type AclMember = { characterId: number; role: string }; + +/** + * Spec job 3: admin-role entries are NEVER removed; manager-role entries are + * removed like anyone else when they leave the desired set. A desired + * character whose role is "blocked" has no effective access — presence alone + * is not convergence — so it is unblocked (reset to viewer); all other roles + * (admin/manager/member/viewer) are preserved as-is. + */ +export function diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { + add: number[]; + remove: number[]; + unblock: number[]; +} { + const desired = new Set(input.desiredIds); + const byId = new Map(input.members.map((m) => [m.characterId, m])); + return { + add: input.desiredIds.filter((id) => !byId.has(id)), + unblock: input.desiredIds.filter((id) => byId.get(id)?.role === "blocked"), + remove: input.members + .filter((m) => !desired.has(m.characterId) && m.role !== "admin") + .map((m) => m.characterId), + }; +} diff --git a/src/jobs/wanderer.ts b/src/jobs/wanderer.ts new file mode 100644 index 00000000..5a917545 --- /dev/null +++ b/src/jobs/wanderer.ts @@ -0,0 +1,126 @@ +import type { Db } from "@/db"; +import { wandererAclObservation } from "@/db/schema"; +import { diffAcl } from "@/core/acl-diff"; +import { WandererError, type WandererClient } from "@/lib/wanderer/client"; +import { logAudit } from "@/services/audit"; +import { getFlygdCharacters } from "@/services/desired"; +import { runJob, type JobResult } from "@/services/sync-run"; + +type CharacterEntry = { characterId: number; role: string }; + +/** The job manages ONLY character entries; corp/alliance members are inert. */ +function characterEntries( + members: Array<{ characterId: number | null; role: string }>, +): CharacterEntry[] { + return members.flatMap((m) => + m.characterId !== null ? [{ characterId: m.characterId, role: m.role }] : [], + ); +} + +const isTransient = (err: unknown): boolean => + err instanceof WandererError ? err.transient : true; + +export async function runWandererJob(deps: { + db: Db; + wanderer: WandererClient; +}): Promise { + const { db, wanderer } = deps; + return runJob(db, "wanderer", async () => { + const desiredIds = (await getFlygdCharacters(db)).map((c) => c.characterId); + + // Never remove on unknown state: a failed read aborts before ANY mutation. + let members; + try { + members = await wanderer.getAclMembers(); + } catch (err) { + return { + status: "failed", + errorSummary: `ACL read failed: ${err instanceof Error ? err.message : String(err)}`, + ...(isTransient(err) ? { retry: true } : {}), + }; + } + + const diff = diffAcl({ desiredIds, members: characterEntries(members) }); + const errors: string[] = []; + let anyTransient = false; + let added = 0; + let removed = 0; + for (const id of diff.add) { + try { + await wanderer.addAclMember(id); + added++; + await logAudit(db, { actor: "system", action: "wanderer.added", target: String(id) }); + } catch (err) { + anyTransient ||= isTransient(err); + errors.push(`add ${id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + for (const id of diff.remove) { + try { + await wanderer.removeAclMember(id); + removed++; + await logAudit(db, { actor: "system", action: "wanderer.removed", target: String(id) }); + } catch (err) { + anyTransient ||= isTransient(err); + errors.push(`remove ${id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + // A blocked desired member has no effective access — reset to viewer. + let unblocked = 0; + for (const id of diff.unblock) { + try { + await wanderer.updateAclMemberRole(id, "viewer"); + unblocked++; + await logAudit(db, { actor: "system", action: "wanderer.unblocked", target: String(id) }); + } catch (err) { + anyTransient ||= isTransient(err); + errors.push(`unblock ${id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Persist the POST-mutation state (spec: the UI never shows pre-mutation + // state). No mutation → the initial read is already the live state. + let observed: typeof members | null = members; + if (added + removed + unblocked > 0 || errors.length > 0) { + try { + observed = await wanderer.getAclMembers(); + } catch (err) { + observed = null; // keep the previous observation: stale but honest + anyTransient ||= isTransient(err); + } + } + if (observed !== null) { + const rows = characterEntries(observed); + const observedAt = new Date(); + await db.transaction(async (tx) => { + await tx.delete(wandererAclObservation); + if (rows.length > 0) { + await tx.insert(wandererAclObservation).values( + rows.map((m) => ({ characterId: m.characterId, role: m.role, observedAt })), + ); + } + }); + } + + const counts = { + added, + removed, + unblocked, + addFailed: diff.add.length - added, + removeFailed: diff.remove.length - removed, + unblockFailed: diff.unblock.length - unblocked, + }; + if (errors.length > 0 || observed === null) { + return { + status: "partial", + errorSummary: [...errors, ...(observed === null ? ["post-mutation re-read failed"] : [])] + .slice(0, 5) + .join("; "), + counts, + // Preserve classification: only transient trouble earns a retry. + ...(anyTransient ? { retry: true } : {}), + }; + } + return { status: "ok", counts }; + }); +} diff --git a/src/lib/wanderer/client.ts b/src/lib/wanderer/client.ts new file mode 100644 index 00000000..ee13967e --- /dev/null +++ b/src/lib/wanderer/client.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import type { Config } from "@/config"; + +// Wanderer ACL API — contract confirmed 2026-08-02 against wanderer source +// (access_list_api_controller.ex / access_list_member_api_controller.ex): +// GET /api/acls/:aclId → { data: { ..., members: [...] } } +// POST /api/acls/:aclId/members → { data: {...member} } (name resolved server-side) +// DELETE /api/acls/:aclId/members/:id → { ok: true }; 404 = not a member (idempotent) +// :id is the EVE character/corp/alliance id, NOT the member row's UUID. Each +// member carries exactly one of eve_character_id / eve_corporation_id / +// eve_alliance_id; non-character members surface here as characterId: null. + +export class WandererError extends Error { + status?: number; + transient: boolean; + constructor(message: string, opts: { status?: number; transient: boolean }) { + super(message); + this.status = opts.status; + this.transient = opts.transient; + } +} + +const eveIdSchema = z.union([z.string().regex(/^\d+$/), z.number().int()]); +const roleSchema = z.enum(["admin", "manager", "member", "viewer", "blocked"]); +// Strict on both axes, fail closed: an unknown role spelling could cost an +// entry its admin protection, and a member with zero/multiple external ids +// violates the documented contract — either rejects the WHOLE read, so the +// job never mutates from a misunderstood ACL. +const memberSchema = z + .object({ + role: roleSchema, + eve_character_id: eveIdSchema.nullish(), + eve_corporation_id: eveIdSchema.nullish(), + eve_alliance_id: eveIdSchema.nullish(), + }) + .refine( + (m) => + [m.eve_character_id, m.eve_corporation_id, m.eve_alliance_id].filter( + (v) => v != null, + ).length === 1, + { message: "ACL member must carry exactly one external id" }, + ); +const aclSchema = z.object({ + data: z.object({ members: z.array(memberSchema) }), +}); + +export type AclRole = z.infer; +export type WandererAclMember = { characterId: number | null; role: AclRole }; + +export function createWandererClient(cfg: Config, fetchImpl: typeof fetch = fetch) { + const base = cfg.wanderer.baseUrl.replace(/\/$/, ""); + const aclPath = `/api/acls/${cfg.wanderer.aclId}`; + const membersPath = `${aclPath}/members`; + + async function rawRequest(path: string, init: RequestInit = {}): Promise { + try { + return await fetchImpl(`${base}${path}`, { + ...init, + headers: { + authorization: `Bearer ${cfg.wanderer.apiKey}`, + "content-type": "application/json", + ...(init.headers as Record | undefined), + }, + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + throw new WandererError( + `wanderer request failed: ${err instanceof Error ? err.message : String(err)}`, + { transient: true }, + ); + } + } + + function assertOk(res: Response, method: string, path: string): Response { + if (!res.ok) { + throw new WandererError(`wanderer ${method} ${path} failed (${res.status})`, { + status: res.status, + transient: res.status === 429 || res.status >= 500, + }); + } + return res; + } + + async function request(path: string, init: RequestInit = {}): Promise { + return assertOk(await rawRequest(path, init), init.method ?? "GET", path); + } + + return { + async getAclMembers(): Promise { + const res = await request(aclPath); + return aclSchema.parse(await res.json()).data.members.map((m) => ({ + characterId: m.eve_character_id != null ? Number(m.eve_character_id) : null, + role: m.role, + })); + }, + async addAclMember(characterId: number): Promise { + // role "viewer" (wanderer's default); name is resolved server-side. + await request(membersPath, { + method: "POST", + body: JSON.stringify({ + member: { eve_character_id: String(characterId), role: "viewer" }, + }), + }); + }, + async updateAclMemberRole(characterId: number, role: AclRole): Promise { + // keyed by EVE id, not the member row's UUID + await request(`${membersPath}/${characterId}`, { + method: "PUT", + body: JSON.stringify({ member: { role } }), + }); + }, + async removeAclMember(characterId: number): Promise { + const path = `${membersPath}/${characterId}`; + const res = await rawRequest(path, { method: "DELETE" }); + if (res.status === 404) return; // already not a member — idempotent + assertOk(res, "DELETE", path); + }, + }; +} + +export type WandererClient = ReturnType; diff --git a/tests/acl-diff.test.ts b/tests/acl-diff.test.ts new file mode 100644 index 00000000..74fa5982 --- /dev/null +++ b/tests/acl-diff.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { diffAcl } from "@/core/acl-diff"; + +describe("diffAcl", () => { + it("adds missing desired members and removes undesired ones", () => { + expect( + diffAcl({ + desiredIds: [1, 2], + members: [ + { characterId: 2, role: "member" }, + { characterId: 3, role: "member" }, + ], + }), + ).toEqual({ add: [1], remove: [3], unblock: [] }); + }); + + it("NEVER removes admin-role entries; managers are removable", () => { + expect( + diffAcl({ + desiredIds: [], + members: [ + { characterId: 1, role: "admin" }, + { characterId: 2, role: "manager" }, + { characterId: 3, role: "member" }, + ], + }), + ).toEqual({ add: [], remove: [2, 3], unblock: [] }); + }); + + it("unblocks desired blocked members, preserving all other roles", () => { + expect( + diffAcl({ + desiredIds: [1, 2, 3, 4], + members: [ + { characterId: 1, role: "blocked" }, // desired but blocked → unblock + { characterId: 2, role: "manager" }, // elevated → preserved + { characterId: 3, role: "viewer" }, // normal → preserved + { characterId: 4, role: "admin" }, // elevated → preserved + { characterId: 5, role: "blocked" }, // blocked AND undesired → removed + ], + }), + ).toEqual({ add: [], remove: [5], unblock: [1] }); + }); + + it("is a no-op when converged", () => { + expect( + diffAcl({ desiredIds: [1], members: [{ characterId: 1, role: "member" }] }), + ).toEqual({ add: [], remove: [], unblock: [] }); + }); +}); diff --git a/tests/wanderer-client.test.ts b/tests/wanderer-client.test.ts new file mode 100644 index 00000000..8c3aa0d8 --- /dev/null +++ b/tests/wanderer-client.test.ts @@ -0,0 +1,135 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createWandererClient, WandererError } from "@/lib/wanderer/client"; +import { testConfig } from "./helpers/config"; + +const cfg = testConfig(); // base https://wanderer.example, aclId acl-1 +const ACL = "https://wanderer.example/api/acls/acl-1"; +const MEMBERS = `${ACL}/members`; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const aclResponse = (members: unknown[]) => + HttpResponse.json({ + data: { id: "uuid", name: "My ACL", members }, + }); + +describe("createWandererClient", () => { + it("reads the ACL with bearer auth; corp/alliance members become characterId null", async () => { + server.use( + http.get(ACL, ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bearer wkey"); + return aclResponse([ + { id: "m1", name: "Pilot A", eve_character_id: "90000001", role: "admin" }, + { id: "m2", name: "Pilot B", eve_character_id: "90000002", role: "viewer" }, + { id: "m3", name: "Some Corp", eve_corporation_id: "98000001", role: "viewer" }, + { id: "m4", name: "Some Alliance", eve_alliance_id: "99000009", role: "blocked" }, + ]); + }), + ); + const w = createWandererClient(cfg); + expect(await w.getAclMembers()).toEqual([ + { characterId: 90000001, role: "admin" }, + { characterId: 90000002, role: "viewer" }, + { characterId: null, role: "viewer" }, + { characterId: null, role: "blocked" }, + ]); + }); + + it("fails closed on malformed member payloads", async () => { + server.use( + http.get(ACL, () => + aclResponse([{ eve_character_id: "not-digits", role: "viewer" }]), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + + it("fails closed on an unknown role spelling (admin protection depends on it)", async () => { + server.use( + http.get(ACL, () => + aclResponse([{ eve_character_id: "90000001", role: "administrator" }]), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + + it("fails closed on zero or multiple external ids", async () => { + server.use(http.get(ACL, () => aclResponse([{ role: "viewer" }]))); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + server.use( + http.get(ACL, () => + aclResponse([ + { eve_character_id: "1", eve_corporation_id: "2", role: "viewer" }, + ]), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + + it("updates a member's role via PUT keyed by EVE id", async () => { + let putId = ""; + let putBody: unknown; + server.use( + http.put(`${MEMBERS}/:id`, async ({ params, request }) => { + putId = params.id as string; + putBody = await request.json(); + return HttpResponse.json({ + data: { id: "uuid", name: "Pilot", role: "viewer", eve_character_id: putId }, + }); + }), + ); + await createWandererClient(cfg).updateAclMemberRole(90000006, "viewer"); + expect(putId).toBe("90000006"); + expect(putBody).toEqual({ member: { role: "viewer" } }); + }); + + it("classifies 5xx as transient and 403 as permanent", async () => { + server.use(http.get(ACL, () => HttpResponse.json({}, { status: 502 }))); + let err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); + expect(err).toBeInstanceOf(WandererError); + expect((err as WandererError).transient).toBe(true); + + server.use(http.get(ACL, () => HttpResponse.json({}, { status: 403 }))); + err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); + expect((err as WandererError).transient).toBe(false); + }); + + it("adds members as viewer without a name, and deletes by EVE id", async () => { + const posts: unknown[] = []; + let deleted = ""; + server.use( + http.post(MEMBERS, async ({ request }) => { + posts.push(await request.json()); + return HttpResponse.json({ + data: { id: "uuid", name: "Resolved Server-Side", role: "viewer", eve_character_id: "90000003" }, + }); + }), + http.delete(`${MEMBERS}/:id`, ({ params }) => { + deleted = params.id as string; + return HttpResponse.json({ ok: true }); + }), + ); + const w = createWandererClient(cfg); + await w.addAclMember(90000003); + await w.removeAclMember(90000004); + expect(posts).toEqual([{ member: { eve_character_id: "90000003", role: "viewer" } }]); + expect(deleted).toBe("90000004"); + }); + + it("treats a 404 on delete as idempotent success", async () => { + server.use( + http.delete(`${MEMBERS}/:id`, () => + HttpResponse.json( + { error: "Membership not found for given ACL and external id" }, + { status: 404 }, + ), + ), + ); + await expect(createWandererClient(cfg).removeAclMember(90000005)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/wanderer-job.test.ts b/tests/wanderer-job.test.ts new file mode 100644 index 00000000..0962cf39 --- /dev/null +++ b/tests/wanderer-job.test.ts @@ -0,0 +1,183 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { auditLog, wandererAclObservation } from "@/db/schema"; +import { runWandererJob } from "@/jobs/wanderer"; +import { + WandererError, + type WandererAclMember, + type WandererClient, +} from "@/lib/wanderer/client"; +import { JobRetryError } from "@/services/sync-run"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +type Member = WandererAclMember; + +/** Fake Wanderer with a mutable member list and scriptable failures. */ +function fakeWanderer(initial: Member[], opts: { + failFirstRead?: boolean; + failReRead?: boolean; + failRemoveOf?: number; + /** When set with failRemoveOf, the remove failure is permanent (transient: false). */ + permanentRemoveFailure?: boolean; +} = {}) { + let members = [...initial]; + let reads = 0; + const client: WandererClient = { + getAclMembers: async () => { + reads++; + if (opts.failFirstRead && reads === 1) { + throw new WandererError("read failed", { status: 502, transient: true }); + } + if (opts.failReRead && reads > 1) { + throw new WandererError("re-read failed", { status: 502, transient: true }); + } + return [...members]; + }, + addAclMember: async (id) => { + members.push({ characterId: id, role: "viewer" }); + }, + updateAclMemberRole: async (id, role) => { + members = members.map((m) => (m.characterId === id ? { ...m, role } : m)); + }, + removeAclMember: async (id) => { + if (opts.failRemoveOf === id) { + throw new WandererError("remove failed", { + status: opts.permanentRemoveFailure ? 400 : 500, + transient: !opts.permanentRemoveFailure, + }); + } + members = members.filter((m) => m.characterId !== id); + }, + }; + return { client, members: () => members, reads: () => reads }; +} + +async function seedFlygdChar(id: number) { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id, accountId: acc.id, main: true }); +} + +describe("runWandererJob", () => { + it("adds desired, removes undesired (never admins), persists the POST-mutation read", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([ + { characterId: 2, role: "member" }, + { characterId: 3, role: "admin" }, + { characterId: 4, role: "manager" }, + { characterId: null, role: "viewer" }, // corp/alliance entry — never touched + ]); + const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ added: 1, removed: 2 }); + expect(w.reads()).toBe(2); // initial + post-mutation + // corp/alliance entry survived untouched… + expect(w.members().some((m) => m.characterId === null)).toBe(true); + // …and the observation holds only character entries + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => [o.characterId, o.role]).sort()).toEqual([ + [1, "viewer"], + [3, "admin"], + ]); + const audits = await ctx.db.select().from(auditLog); + expect(audits.filter((a) => a.action === "wanderer.removed")).toHaveLength(2); + expect(audits.filter((a) => a.action === "wanderer.added")).toHaveLength(1); + }); + + it("aborts before ANY mutation when the initial read fails", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 2, role: "member" }], { failFirstRead: true }); + await expect( + runWandererJob({ db: ctx.db, wanderer: w.client }), + ).rejects.toBeInstanceOf(JobRetryError); + expect(w.members()).toEqual([{ characterId: 2, role: "member" }]); // untouched + expect(await ctx.db.select().from(wandererAclObservation)).toEqual([]); + }); + + it("persists the initial read as the observation when nothing needs mutating", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 1, role: "member" }]); + await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(w.reads()).toBe(1); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed).toHaveLength(1); + expect(observed[0].characterId).toBe(1); + }); + + it("still re-reads and persists after a partial mutation failure, then retries", async () => { + await seedFlygdChar(1); + const w = fakeWanderer( + [ + { characterId: 2, role: "member" }, + { characterId: 5, role: "member" }, + ], + { failRemoveOf: 5 }, + ); + await expect( + runWandererJob({ db: ctx.db, wanderer: w.client }), + ).rejects.toBeInstanceOf(JobRetryError); + const observed = await ctx.db.select().from(wandererAclObservation); + // 5's removal failed, so the post-mutation read still contains it — and + // the observation reflects that reality, not the desired state. + expect(observed.map((o) => o.characterId).sort((a, b) => a - b)).toEqual([1, 5]); + }); + + it("unblocks a desired blocked member and observes the new role", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 1, role: "blocked" }]); + const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ unblocked: 1, added: 0, removed: 0 }); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ characterId: 1, role: "viewer" }); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "wanderer.unblocked")).toBe(true); + }); + + it("does NOT retry when every failure was permanent", async () => { + await seedFlygdChar(1); + const w = fakeWanderer( + [ + { characterId: 1, role: "viewer" }, + { characterId: 5, role: "member" }, + ], + { failRemoveOf: 5, permanentRemoveFailure: true }, + ); + // returned, not thrown: permanent failures must not retry-loop + const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + expect(result.status).toBe("partial"); + expect(result.retry).toBeUndefined(); + }); + + it("leaves the previous observation untouched when the re-read fails", async () => { + await seedFlygdChar(1); + await ctx.db.insert(wandererAclObservation).values({ + characterId: 42, + role: "member", + observedAt: new Date(), + }); + const w = fakeWanderer([{ characterId: 2, role: "member" }], { failReRead: true }); + await expect( + runWandererJob({ db: ctx.db, wanderer: w.client }), + ).rejects.toBeInstanceOf(JobRetryError); + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => o.characterId)).toEqual([42]); // stale but honest + }); +}); From f312d2343c5a5812046b444f919301dcf75ad723 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 21:59:32 -0400 Subject: [PATCH 14/28] feat: discord role sync with permanent-config validation --- src/core/role-diff.ts | 69 +++++++++++ src/jobs/discord-roles.ts | 156 +++++++++++++++++++++++++ src/lib/discord/rest.ts | 91 +++++++++++++++ tests/discord-rest.test.ts | 74 ++++++++++++ tests/discord-roles-job.test.ts | 195 ++++++++++++++++++++++++++++++++ tests/role-diff.test.ts | 87 ++++++++++++++ 6 files changed, 672 insertions(+) create mode 100644 src/core/role-diff.ts create mode 100644 src/jobs/discord-roles.ts create mode 100644 src/lib/discord/rest.ts create mode 100644 tests/discord-rest.test.ts create mode 100644 tests/discord-roles-job.test.ts create mode 100644 tests/role-diff.test.ts diff --git a/src/core/role-diff.ts b/src/core/role-diff.ts new file mode 100644 index 00000000..e5cb85a2 --- /dev/null +++ b/src/core/role-diff.ts @@ -0,0 +1,69 @@ +export type ManagedRoleIds = { flygd: string; blue: string; green: string }; + +/** Ensure exactly the tier's role among the three managed roles; all other roles untouched. */ +export function diffRoles(input: { + tier: "flygd" | "blue" | "green"; + managed: ManagedRoleIds; + memberRoleIds: string[]; +}): { add: string[]; remove: string[] } { + const want = input.managed[input.tier]; + const managedAll = [input.managed.flygd, input.managed.blue, input.managed.green]; + const have = new Set(input.memberRoleIds); + return { + add: have.has(want) ? [] : [want], + remove: managedAll.filter((r) => r !== want && have.has(r)), + }; +} + +/** The managed roles a member currently carries (unlinked-user deprovision). */ +export function stripManagedRoles( + managed: ManagedRoleIds, + memberRoleIds: string[], +): string[] { + const managedAll = new Set([managed.flygd, managed.blue, managed.green]); + return memberRoleIds.filter((r) => managedAll.has(r)); +} + +const MANAGE_ROLES = 1n << 28n; +const ADMINISTRATOR = 1n << 3n; + +/** + * Config validation: three distinct managed role ids that exist in the guild; + * bot has Manage Roles (or Administrator); bot's highest role sits ABOVE + * every managed role. Failure is permanent-config — no retry loop. + */ +export function validateRoleConfig(input: { + managed: ManagedRoleIds; + guildRoles: Array<{ id: string; position: number; permissions: string }>; + botRoleIds: string[]; +}): { ok: true } | { ok: false; error: string } { + const ids = [input.managed.flygd, input.managed.blue, input.managed.green]; + if (new Set(ids).size !== 3) { + return { ok: false, error: "managed role ids are not distinct" }; + } + const byId = new Map(input.guildRoles.map((r) => [r.id, r])); + const missing = ids.filter((id) => !byId.has(id)); + if (missing.length > 0) { + return { ok: false, error: `managed roles missing from guild: ${missing.join(", ")}` }; + } + const botRoles = input.botRoleIds.flatMap((id) => { + const role = byId.get(id); + return role ? [role] : []; + }); + const canManage = botRoles.some( + (r) => (BigInt(r.permissions) & (MANAGE_ROLES | ADMINISTRATOR)) !== 0n, + ); + if (!canManage) return { ok: false, error: "bot lacks Manage Roles" }; + const botTop = botRoles.reduce((max, r) => Math.max(max, r.position), -1); + const tooHigh = ids.filter((id) => { + const role = byId.get(id); + return role !== undefined && role.position >= botTop; + }); + if (tooHigh.length > 0) { + return { + ok: false, + error: `bot's highest role is not above managed roles: ${tooHigh.join(", ")}`, + }; + } + return { ok: true }; +} diff --git a/src/jobs/discord-roles.ts b/src/jobs/discord-roles.ts new file mode 100644 index 00000000..5477b7a0 --- /dev/null +++ b/src/jobs/discord-roles.ts @@ -0,0 +1,156 @@ +import { eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { account, discordLink } from "@/db/schema"; +import { diffRoles, stripManagedRoles, validateRoleConfig } from "@/core/role-diff"; +import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; +import { postOpsWebhook } from "@/lib/ops-webhook"; +import { logAudit } from "@/services/audit"; +import { enqueueSync } from "@/services/outbox"; +import { runJob, type JobResult } from "@/services/sync-run"; + +export async function runDiscordRolesJob( + deps: { db: Db; cfg: Config; discord: DiscordClient; fetchImpl?: typeof fetch }, + opts: { accountId?: string; discordUserId?: string } = {}, +): Promise { + const { db, cfg, discord } = deps; + return runJob(db, "discord-roles", async () => { + // Config validation FIRST, every run. A validation failure is + // permanent-config: alert immediately and do NOT retry-loop. The same goes + // for PERMANENT errors fetching the config data (401/403 = bad bot token + // or missing access); only transient fetch errors throw → pg-boss retries. + let guildRoles; + let botMember; + try { + guildRoles = await discord.getGuildRoles(); + botMember = await discord.getGuildMember(await discord.getBotUserId()); + } catch (err) { + if (err instanceof DiscordApiError && !err.transient) { + const msg = `discord config check failed: ${err.message}`; + await postOpsWebhook(cfg, `authGD: ${msg}`, deps.fetchImpl); + return { status: "failed", errorSummary: msg }; + } + throw err; + } + const validation = botMember + ? validateRoleConfig({ + managed: cfg.discord.roleIds, + guildRoles, + botRoleIds: botMember.roles, + }) + : ({ ok: false, error: "bot is not a member of the configured guild" } as const); + if (!validation.ok) { + await postOpsWebhook( + cfg, + `authGD: discord role sync config invalid — ${validation.error}`, + deps.fetchImpl, + ); + return { status: "failed", errorSummary: validation.error }; + } + + // {kind:"discord-user"} deprovision payload: strip managed roles from a + // user who unlinked. If they re-linked meanwhile, the account path owns it. + if (opts.discordUserId) { + const links = await db + .select() + .from(discordLink) + .where(eq(discordLink.discordUserId, opts.discordUserId)); + if (links.length > 0) { + return { status: "ok", counts: { skipped: 1 } as Record }; + } + const member = await discord.getGuildMember(opts.discordUserId); + if (!member) { + return { status: "ok", counts: { notInGuild: 1 } as Record }; + } + const remove = stripManagedRoles(cfg.discord.roleIds, member.roles); + for (const roleId of remove) { + await discord.removeMemberRole(opts.discordUserId, roleId); + } + if (remove.length > 0) { + await logAudit(db, { + actor: "system", + action: "discord.role_changed", + target: opts.discordUserId, + details: { removed: remove, cause: "discord unlinked" }, + }); + } + // Last-writer race: a re-link may have landed (and its role sync run) + // WHILE we stripped. Re-check; if linked now, hand ownership back to the + // account path with a fresh outbox row so the roles are re-asserted. + const relinked = await db + .select() + .from(discordLink) + .where(eq(discordLink.discordUserId, opts.discordUserId)); + if (relinked.length > 0) { + await enqueueSync(db, { kind: "account", accountId: relinked[0].accountId }); + return { + status: "ok", + counts: { removed: remove.length, relinkResync: 1 } as Record, + }; + } + return { + status: "ok", + counts: { removed: remove.length } as Record, + }; + } + + const rows = await db + .select({ + accountId: account.id, + tier: account.tier, + discordUserId: discordLink.discordUserId, + }) + .from(discordLink) + .innerJoin(account, eq(discordLink.accountId, account.id)) + .where(opts.accountId ? eq(account.id, opts.accountId) : undefined); + + const counts = { changed: 0, notInGuild: 0, failed: 0 }; + let transientFailures = 0; + const errors: string[] = []; + for (const row of rows) { + try { + const member = await discord.getGuildMember(row.discordUserId); + if (!member) { + counts.notInGuild++; // user not in guild → log and skip + continue; + } + const diff = diffRoles({ + tier: row.tier, + managed: cfg.discord.roleIds, + memberRoleIds: member.roles, + }); + for (const roleId of diff.add) { + await discord.addMemberRole(row.discordUserId, roleId); + } + for (const roleId of diff.remove) { + await discord.removeMemberRole(row.discordUserId, roleId); + } + if (diff.add.length + diff.remove.length > 0) { + counts.changed++; + await logAudit(db, { + actor: "system", + action: "discord.role_changed", + target: row.discordUserId, + details: { added: diff.add, removed: diff.remove, tier: row.tier }, + }); + } + } catch (err) { + if (err instanceof DiscordApiError && !err.transient) counts.failed++; + else transientFailures++; + errors.push( + `${row.discordUserId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (transientFailures > 0 || counts.failed > 0) { + return { + status: "partial", + errorSummary: errors.slice(0, 5).join("; "), + counts, + retry: transientFailures > 0, + }; + } + return { status: "ok", counts }; + }); +} diff --git a/src/lib/discord/rest.ts b/src/lib/discord/rest.ts new file mode 100644 index 00000000..161e96ec --- /dev/null +++ b/src/lib/discord/rest.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; +import type { Config } from "@/config"; + +const API = "https://discord.com/api/v10"; + +export class DiscordApiError extends Error { + status?: number; + transient: boolean; + constructor(message: string, opts: { status?: number; transient: boolean }) { + super(message); + this.status = opts.status; + this.transient = opts.transient; + } +} + +const roleSchema = z.object({ + id: z.string(), + name: z.string(), + position: z.number().int(), + permissions: z.string(), +}); +const memberSchema = z.object({ roles: z.array(z.string()) }); +const userSchema = z.object({ id: z.string() }); + +export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch) { + async function rawRequest(path: string, init: RequestInit = {}): Promise { + try { + return await fetchImpl(`${API}${path}`, { + ...init, + headers: { + authorization: `Bot ${cfg.discord.botToken}`, + "content-type": "application/json", + ...(init.headers as Record | undefined), + }, + signal: AbortSignal.timeout(30_000), + }); + } catch (err) { + throw new DiscordApiError( + `discord request failed: ${err instanceof Error ? err.message : String(err)}`, + { transient: true }, + ); + } + } + + function assertOk(res: Response, method: string, path: string): Response { + if (!res.ok) { + throw new DiscordApiError(`discord ${method} ${path} failed (${res.status})`, { + status: res.status, + transient: res.status === 429 || res.status >= 500, + }); + } + return res; + } + + async function request(path: string, init: RequestInit = {}): Promise { + return assertOk(await rawRequest(path, init), init.method ?? "GET", path); + } + + const guild = cfg.discord.guildId; + + return { + async getGuildRoles() { + const res = await request(`/guilds/${guild}/roles`); + return z.array(roleSchema).parse(await res.json()); + }, + async getBotUserId(): Promise { + const res = await request("/users/@me"); + return userSchema.parse(await res.json()).id; + }, + /** null when the user is not in the guild (404). */ + async getGuildMember(userId: string): Promise<{ roles: string[] } | null> { + const path = `/guilds/${guild}/members/${userId}`; + const res = await rawRequest(path); + if (res.status === 404) return null; + assertOk(res, "GET", path); + return memberSchema.parse(await res.json()); + }, + async addMemberRole(userId: string, roleId: string): Promise { + await request(`/guilds/${guild}/members/${userId}/roles/${roleId}`, { + method: "PUT", + }); + }, + async removeMemberRole(userId: string, roleId: string): Promise { + await request(`/guilds/${guild}/members/${userId}/roles/${roleId}`, { + method: "DELETE", + }); + }, + }; +} + +export type DiscordClient = ReturnType; diff --git a/tests/discord-rest.test.ts b/tests/discord-rest.test.ts new file mode 100644 index 00000000..86e41e39 --- /dev/null +++ b/tests/discord-rest.test.ts @@ -0,0 +1,74 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDiscordClient, DiscordApiError } from "@/lib/discord/rest"; +import { testConfig } from "./helpers/config"; + +const cfg = testConfig(); // guild 9000, bot token "bot-token" +const API = "https://discord.com/api/v10"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("createDiscordClient", () => { + it("sends bot auth and parses guild roles", async () => { + server.use( + http.get(`${API}/guilds/9000/roles`, ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bot bot-token"); + return HttpResponse.json([ + { id: "10", name: "FlyGD", position: 5, permissions: "0", extra: "ignored" }, + ]); + }), + ); + expect(await createDiscordClient(cfg).getGuildRoles()).toEqual([ + { id: "10", name: "FlyGD", position: 5, permissions: "0" }, + ]); + }); + + it("returns null for a 404 guild member (user not in guild)", async () => { + server.use( + http.get(`${API}/guilds/9000/members/u1`, () => + HttpResponse.json({ message: "Unknown Member" }, { status: 404 }), + ), + ); + expect(await createDiscordClient(cfg).getGuildMember("u1")).toBeNull(); + }); + + it("classifies 429 as transient", async () => { + server.use( + http.get(`${API}/guilds/9000/members/u1`, () => + HttpResponse.json({}, { status: 429 }), + ), + ); + const err = await createDiscordClient(cfg).getGuildMember("u1").catch((e: unknown) => e); + expect(err).toBeInstanceOf(DiscordApiError); + expect((err as DiscordApiError).transient).toBe(true); + }); + + it("adds and removes member roles via PUT/DELETE", async () => { + const calls: string[] = []; + server.use( + http.put(`${API}/guilds/9000/members/u1/roles/10`, () => { + calls.push("put"); + return new HttpResponse(null, { status: 204 }); + }), + http.delete(`${API}/guilds/9000/members/u1/roles/11`, () => { + calls.push("delete"); + return new HttpResponse(null, { status: 204 }); + }), + ); + const d = createDiscordClient(cfg); + await d.addMemberRole("u1", "10"); + await d.removeMemberRole("u1", "11"); + expect(calls).toEqual(["put", "delete"]); + }); + + it("resolves the bot user id", async () => { + server.use( + http.get(`${API}/users/@me`, () => HttpResponse.json({ id: "bot-user" })), + ); + expect(await createDiscordClient(cfg).getBotUserId()).toBe("bot-user"); + }); +}); diff --git a/tests/discord-roles-job.test.ts b/tests/discord-roles-job.test.ts new file mode 100644 index 00000000..dc044e95 --- /dev/null +++ b/tests/discord-roles-job.test.ts @@ -0,0 +1,195 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { auditLog, outbox, syncRun } from "@/db/schema"; +import { runDiscordRolesJob } from "@/jobs/discord-roles"; +import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); // managed roles 10/11/12 + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const MANAGE_ROLES = String(1 << 28); +const validGuildRoles = [ + { id: "10", name: "FlyGD", position: 5, permissions: "0" }, + { id: "11", name: "Blue", position: 4, permissions: "0" }, + { id: "12", name: "Green", position: 3, permissions: "0" }, + { id: "bot-role", name: "Bot", position: 9, permissions: MANAGE_ROLES }, +]; + +function fakeDiscord(members: Record, guildRoles = validGuildRoles) { + const added: Array<[string, string]> = []; + const removed: Array<[string, string]> = []; + const client: DiscordClient = { + getGuildRoles: async () => guildRoles, + getBotUserId: async () => "bot-user", + getGuildMember: async (userId) => { + if (userId === "bot-user") return { roles: ["bot-role"] }; + const roles = members[userId]; + return roles === null || roles === undefined ? null : { roles }; + }, + addMemberRole: async (userId, roleId) => { + added.push([userId, roleId]); + }, + removeMemberRole: async (userId, roleId) => { + removed.push([userId, roleId]); + }, + }; + return { client, added, removed }; +} + +describe("runDiscordRolesJob", () => { + it("ensures exactly the tier's managed role, leaving other roles alone", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u1" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const d = fakeDiscord({ u1: ["11", "999"] }); + const result = await runDiscordRolesJob({ db: ctx.db, cfg, discord: d.client }); + expect(result.status).toBe("ok"); + expect(d.added).toEqual([["u1", "10"]]); + expect(d.removed).toEqual([["u1", "11"]]); // 999 untouched + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "discord.role_changed")).toBe(true); + }); + + it("config validation failure is permanent: failed run, webhook, NO retry", async () => { + const badRoles = validGuildRoles.filter((r) => r.id !== "11"); // blue missing + const d = fakeDiscord({}, badRoles); + const webhook = vi.fn(async () => new Response("", { status: 204 })); + const result = await runDiscordRolesJob({ + db: ctx.db, + cfg, + discord: d.client, + fetchImpl: webhook as unknown as typeof fetch, + }); + expect(result.status).toBe("failed"); // returned, not thrown → no retry loop + expect(webhook).toHaveBeenCalledOnce(); + const runs = await ctx.db.select().from(syncRun); + expect(runs[0].status).toBe("failed"); + expect(runs[0].errorSummary).toContain("11"); + }); + + it("treats a permanent config-fetch error (403) as permanent-config: no retry", async () => { + const d = fakeDiscord({}); + const client: DiscordClient = { + ...d.client, + getGuildRoles: async () => { + throw new DiscordApiError("discord GET /guilds/9000/roles failed (403)", { + status: 403, + transient: false, + }); + }, + }; + const webhook = vi.fn(async () => new Response("", { status: 204 })); + // returned, not thrown: a bad bot token must not retry-loop + const result = await runDiscordRolesJob({ + db: ctx.db, + cfg, + discord: client, + fetchImpl: webhook as unknown as typeof fetch, + }); + expect(result.status).toBe("failed"); + expect(webhook).toHaveBeenCalledOnce(); + }); + + it("still retries transient config-fetch errors", async () => { + const d = fakeDiscord({}); + const client: DiscordClient = { + ...d.client, + getGuildRoles: async () => { + throw new DiscordApiError("discord GET /guilds/9000/roles failed (503)", { + status: 503, + transient: true, + }); + }, + }; + await expect( + runDiscordRolesJob({ db: ctx.db, cfg, discord: client }), + ).rejects.toThrow(/503/); // thrown → pg-boss retries + }); + + it("logs and skips users not in the guild", async () => { + const acc = await seedAccount(ctx.db, { tier: "green", discordUserId: "gone" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const d = fakeDiscord({ gone: null }); + const result = await runDiscordRolesJob({ db: ctx.db, cfg, discord: d.client }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ notInGuild: 1 }); + expect(d.added).toEqual([]); + }); + + it("strips managed roles from an unlinked discord user ({kind:'discord-user'})", async () => { + const d = fakeDiscord({ u9: ["10", "12", "999"] }); + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: d.client }, + { discordUserId: "u9" }, + ); + expect(result.status).toBe("ok"); + expect(d.removed.sort()).toEqual([ + ["u9", "10"], + ["u9", "12"], + ]); + expect(d.added).toEqual([]); + }); + + it("re-syncs the account when a re-link lands DURING the strip", async () => { + const d = fakeDiscord({ u9: ["10"] }); + // the re-link commits while the strip's role removal is in flight + const client: DiscordClient = { + ...d.client, + removeMemberRole: async (userId, roleId) => { + const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u9" }); + await seedCharacter(ctx.db, cfg, { id: 99, accountId: acc.id, main: true }); + await d.client.removeMemberRole(userId, roleId); + }, + }; + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: client }, + { discordUserId: "u9" }, + ); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ relinkResync: 1 }); + // the account path re-asserts the roles via a fresh outbox row + const rows = await ctx.db.select().from(outbox); + expect(rows.map((r) => r.payload)).toContainEqual( + expect.objectContaining({ kind: "account" }), + ); + }); + + it("skips the strip when the user re-linked meanwhile", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u9" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + const d = fakeDiscord({ u9: ["10"] }); + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: d.client }, + { discordUserId: "u9" }, + ); + expect(result.counts).toMatchObject({ skipped: 1 }); + expect(d.removed).toEqual([]); + }); + + it("scopes to one account when accountId is passed", async () => { + const a1 = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u1" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: a1.id, main: true }); + const a2 = await seedAccount(ctx.db, { tier: "green", discordUserId: "u2" }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: a2.id, main: true }); + const d = fakeDiscord({ u1: [], u2: [] }); + await runDiscordRolesJob( + { db: ctx.db, cfg, discord: d.client }, + { accountId: a1.id }, + ); + expect(d.added).toEqual([["u1", "10"]]); // u2 untouched + }); +}); diff --git a/tests/role-diff.test.ts b/tests/role-diff.test.ts new file mode 100644 index 00000000..fbaa67bb --- /dev/null +++ b/tests/role-diff.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { diffRoles, stripManagedRoles, validateRoleConfig } from "@/core/role-diff"; + +const managed = { flygd: "10", blue: "11", green: "12" }; + +describe("diffRoles", () => { + it("adds the tier role and removes the other managed roles only", () => { + expect( + diffRoles({ tier: "flygd", managed, memberRoleIds: ["11", "12", "999"] }), + ).toEqual({ add: ["10"], remove: ["11", "12"] }); + }); + it("is a no-op when exactly the tier role is present", () => { + expect(diffRoles({ tier: "green", managed, memberRoleIds: ["12", "999"] })).toEqual({ + add: [], + remove: [], + }); + }); +}); + +describe("stripManagedRoles", () => { + it("returns only the managed roles the member has", () => { + expect(stripManagedRoles(managed, ["11", "999", "12"])).toEqual(["11", "12"]); + expect(stripManagedRoles(managed, ["999"])).toEqual([]); + }); +}); + +describe("validateRoleConfig", () => { + const MANAGE_ROLES = String(1 << 28); + const guildRoles = [ + { id: "10", position: 5, permissions: "0" }, + { id: "11", position: 4, permissions: "0" }, + { id: "12", position: 3, permissions: "0" }, + { id: "bot-role", position: 9, permissions: MANAGE_ROLES }, + ]; + + it("accepts a valid config", () => { + expect( + validateRoleConfig({ managed, guildRoles, botRoleIds: ["bot-role"] }), + ).toEqual({ ok: true }); + }); + it("rejects duplicate managed role ids", () => { + const r = validateRoleConfig({ + managed: { flygd: "10", blue: "10", green: "12" }, + guildRoles, + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false }); + }); + it("rejects managed roles missing from the guild", () => { + const r = validateRoleConfig({ + managed: { ...managed, blue: "404" }, + guildRoles, + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false, error: expect.stringContaining("404") }); + }); + it("rejects a bot without Manage Roles", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, permissions: "0" } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false, error: expect.stringContaining("Manage Roles") }); + }); + it("accepts Administrator in place of Manage Roles", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, permissions: String(1 << 3) } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toEqual({ ok: true }); + }); + it("rejects a bot whose highest role is not above the managed roles", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, position: 4 } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false }); + }); +}); From 0836e26005b6db3225dae6e420cfa3d8cc92394a Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 22:05:08 -0400 Subject: [PATCH 15/28] feat: daily token health job with transfer reclaim and subject binding --- src/jobs/token-health.ts | 124 ++++++++++++++++++ src/services/accounts.ts | 29 +++++ tests/token-health-job.test.ts | 229 +++++++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 src/jobs/token-health.ts create mode 100644 tests/token-health-job.test.ts diff --git a/src/jobs/token-health.ts b/src/jobs/token-health.ts new file mode 100644 index 00000000..92dc4df9 --- /dev/null +++ b/src/jobs/token-health.ts @@ -0,0 +1,124 @@ +import { and, eq } from "drizzle-orm"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { character } from "@/db/schema"; +import { verifyEveAccessToken } from "@/lib/esi/sso"; +import { reclaimTransferredCharacter } from "@/services/accounts"; +import { logAudit } from "@/services/audit"; +import { runJob, type JobResult } from "@/services/sync-run"; +import { getFreshAccessToken } from "@/services/tokens"; + +export async function runTokenHealthJob(deps: { + db: Db; + cfg: Config; + fetchImpl?: typeof fetch; +}): Promise { + const { db, cfg } = deps; + return runJob(db, "token-health", async () => { + const chars = await db.select().from(character); + const counts = { refreshed: 0, invalid: 0, needsReauth: 0, unlinked: 0, skipped: 0 }; + let transientFailures = 0; + + for (const ch of chars) { + if (!ch.refreshTokenEnc || ch.tokenStatus === "invalid") { + counts.skipped++; + continue; + } + const token = await getFreshAccessToken(db, cfg, ch, deps.fetchImpl); + if (!token.ok) { + if (token.reason === "transient") transientFailures++; + else counts.invalid++; // permanent-only invalidation done in the service + continue; + } + const identity = await verifyEveAccessToken(token.accessToken); + + if (identity.characterId !== ch.id) { + // Fail closed: a token whose subject is another character must never + // vouch for this row. Guard on the blob our CAS just stored so a + // concurrent re-auth/reclaim discards this stale decision. + const applied = await db.transaction(async (tx) => { + const rows = await tx + .update(character) + .set({ tokenStatus: "invalid" }) + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), + ) + .returning({ id: character.id }); + if (rows.length === 0) return false; + await logAudit(tx, { + actor: "system", + action: "token.subject_mismatch", + target: String(ch.id), + details: { subjectCharacterId: identity.characterId }, + }); + return true; + }); + if (applied) counts.invalid++; + else transientFailures++; + continue; + } + + if (identity.ownerHash !== ch.ownerHash) { + // Ownership transfer (spec: Auth flows): full reclaim — main cleared, + // demotion unless locked, deprovision jobs enqueued, sessions revoked. + // No last-character guard: transfer legitimately empties accounts. + // The service re-verifies account+owner under the character lock, so a + // transfer that already completed concurrently is never double-applied. + const result = await db.transaction(async (tx) => { + const r = await reclaimTransferredCharacter(tx, ch.id, { + accountId: ch.accountId, + ownerHash: ch.ownerHash, + }); + if (r.ok) { + await logAudit(tx, { + actor: "system", + action: "character.owner_mismatch", + target: String(ch.id), + details: { detectedBy: "token-health" }, + }); + } + return r; + }); + if (result.ok) counts.unlinked++; + else transientFailures++; // row changed underneath — next run decides + continue; + } + + // Scope shortfall vs the CURRENT required set ⇒ needs_reauth (one-click + // in-place re-auth in the UI); full coverage ⇒ valid. Guarded on the + // blob we rotated to — a miss means the row moved on without us. + const covered = cfg.eveSso.scopes.every((s) => identity.scopes.includes(s)); + const nextStatus = covered ? ("valid" as const) : ("needs_reauth" as const); + const statusRows = await db + .update(character) + .set({ scopes: identity.scopes, tokenStatus: nextStatus }) + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), + ) + .returning({ id: character.id }); + if (statusRows.length === 0) { + transientFailures++; + continue; + } + if (nextStatus === "needs_reauth" && ch.tokenStatus !== "needs_reauth") { + await logAudit(db, { + actor: "system", + action: "token.needs_reauth", + target: String(ch.id), + }); + counts.needsReauth++; + } + counts.refreshed++; + } + + if (transientFailures > 0) { + return { + status: "partial", + errorSummary: `${transientFailures} transient refresh failures`, + counts, + retry: true, + }; + } + return { status: "ok", counts }; + }); +} diff --git a/src/services/accounts.ts b/src/services/accounts.ts index 9d1b341d..74df40a0 100644 --- a/src/services/accounts.ts +++ b/src/services/accounts.ts @@ -343,6 +343,35 @@ export async function maybeGrantBootstrapAdmin( return true; } +/** + * Transfer reclaim for background detection (token health): unlike + * unlinkCharacter there is NO last-character guard — that guard exists only + * for ordinary unlink flows, while a sold character always leaves its old + * account, which may legitimately end with zero characters (spec: it stays + * Green until an admin deletes it). Locks, deletes the link, applies the + * no-main rule (demotion unless tier_locked + outbox enqueue), and revokes + * the account's sessions. + */ +export async function reclaimTransferredCharacter( + dbx: DbTx, + characterId: number, + expected: { accountId: string; ownerHash: string }, +): Promise<{ ok: true } | { ok: false; error: "not_found" | "changed" }> { + const existing = await findCharacterForUpdate(dbx, characterId); + if (!existing) return { ok: false, error: "not_found" }; + // Stale-decision guard: re-verify under the lock. If the row already + // changed hands (the new owner's login reclaimed it, or a re-auth updated + // the owner hash), this caller's decision is based on dead data. + if ( + existing.accountId !== expected.accountId || + existing.ownerHash !== expected.ownerHash + ) { + return { ok: false, error: "changed" }; + } + await reclaimCharacter(dbx, existing); + return { ok: true }; +} + export async function demoteAdmin( dbx: DbTx, actor: string, diff --git a/tests/token-health-job.test.ts b/tests/token-health-job.test.ts new file mode 100644 index 00000000..dd7c6db2 --- /dev/null +++ b/tests/token-health-job.test.ts @@ -0,0 +1,229 @@ +import { eq, sql } from "drizzle-orm"; +import { + SignJWT, + createLocalJWKSet, + exportJWK, + generateKeyPair, +} from "jose"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; +import { account, auditLog, character, outbox, session } from "@/db/schema"; +import { runTokenHealthJob } from "@/jobs/token-health"; +import { setTestJwksOverride } from "@/lib/esi/sso"; +import { reclaimTransferredCharacter } from "@/services/accounts"; +import { JobRetryError } from "@/services/sync-run"; +import { createSession } from "@/services/session"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); + +let ctx: Awaited>; +let privateKey: CryptoKey; +beforeAll(async () => { + ctx = await setupTestDb(); + const pair = await generateKeyPair("RS256"); + privateKey = pair.privateKey; + setTestJwksOverride( + createLocalJWKSet({ keys: [{ ...(await exportJWK(pair.publicKey)), alg: "RS256" }] }), + ); +}); +afterAll(() => ctx.cleanup()); +afterAll(() => setTestJwksOverride(undefined)); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +async function signAccessToken(opts: { + characterId: number; + ownerHash: string; + scopes: string[]; +}): Promise { + return new SignJWT({ name: "Pilot", owner: opts.ownerHash, scp: opts.scopes }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("https://login.eveonline.com") + .setAudience("EVE Online") + .setSubject(`CHARACTER:EVE:${opts.characterId}`) + .setExpirationTime("5m") + .sign(privateKey); +} + +/** SSO token endpoint fake returning a signed access token per refresh. */ +function refreshFetchFor(accessTokens: Record): typeof fetch { + return (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = new URLSearchParams(init?.body as string); + const rt = body.get("refresh_token") ?? ""; + const at = accessTokens[rt]; + if (!at) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + } + return new Response( + JSON.stringify({ access_token: at, refresh_token: `${rt}-rotated` }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; +} + +async function getChar(id: number) { + const rows = await ctx.db.select().from(character).where(eq(character.id, id)); + return rows[0]; +} + +describe("runTokenHealthJob", () => { + it("keeps healthy tokens valid and rotates them", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-1", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ refreshed: 1 }); + expect((await getChar(1)).tokenStatus).toBe("valid"); + }); + + it("marks scope shortfalls needs_reauth (in-place re-auth, never unlink)", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-1", + scopes: ["esi-characters.read_contacts.v1"], // write scope missing + }); + await runTokenHealthJob({ db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }) }); + const ch = await getChar(1); + expect(ch.tokenStatus).toBe("needs_reauth"); + expect(ch.scopes).toEqual(["esi-characters.read_contacts.v1"]); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.needs_reauth")).toBe(true); + }); + + it("marks token invalid ONLY on permanent OAuth errors", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "revoked", ownerHash: "oh-1", + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({}), // every refresh → invalid_grant + }); + expect(result.counts).toMatchObject({ invalid: 1 }); + expect((await getChar(1)).tokenStatus).toBe("invalid"); + }); + + it("transient refresh failures change nothing and retry", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + const fetchImpl = (async () => + new Response(JSON.stringify({ error: "temporarily_unavailable" }), { + status: 503, + })) as typeof fetch; + await expect( + runTokenHealthJob({ db: ctx.db, cfg, fetchImpl }), + ).rejects.toBeInstanceOf(JobRetryError); + expect((await getChar(1)).tokenStatus).toBe("valid"); + }); + + it("owner_hash mismatch reclaims the character and revokes the account's sessions", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-old", + }); + await seedCharacter(ctx.db, cfg, { + id: 2, accountId: acc.id, refreshToken: null, tokenStatus: "missing", + }); + await createSession(ctx.db, acc.id); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-NEW", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.counts).toMatchObject({ unlinked: 1 }); + expect(await getChar(1)).toBeUndefined(); // reclaimed + expect(await ctx.db.select().from(session)).toEqual([]); // sessions revoked + // no-main rule applied: main cleared, demoted, deprovision enqueued + const [after] = await ctx.db.select().from(account); + expect(after.mainCharacterId).toBeNull(); + expect(after.tier).toBe("green"); + const outboxRows = await ctx.db.select().from(outbox); + expect(outboxRows.map((r) => r.payload)).toContainEqual({ + kind: "account", + accountId: acc.id, + }); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "character.owner_mismatch")).toBe(true); + expect(audits.some((a) => a.action === "character.reclaimed")).toBe(true); + }); + + it("reclaims even the LAST character — the account may legitimately end empty", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-old", + }); + await createSession(ctx.db, acc.id); + const at = await signAccessToken({ + characterId: 1, ownerHash: "oh-NEW", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.counts).toMatchObject({ unlinked: 1 }); + expect(await getChar(1)).toBeUndefined(); // gone — no last-character guard here + const [after] = await ctx.db.select().from(account); + expect(after.mainCharacterId).toBeNull(); + expect(after.tier).toBe("green"); // deprovisioned, not left flygd + expect(await ctx.db.select().from(session)).toEqual([]); + }); + + it("fails closed when the token's subject is a DIFFERENT character", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + // valid token, same owner hash, but subject character 2 — must never + // vouch for character 1's row + const at = await signAccessToken({ + characterId: 2, ownerHash: "oh-1", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }), + }); + expect(result.counts).toMatchObject({ invalid: 1, unlinked: 0 }); + const ch = await getChar(1); + expect(ch).toBeDefined(); // link kept + expect(ch.tokenStatus).toBe("invalid"); + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.subject_mismatch")).toBe(true); + }); + + it("reclaimTransferredCharacter refuses a stale decision (row changed hands)", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + // the row's CURRENT owner hash is already the new owner's + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, ownerHash: "oh-new-owner", + }); + const r = await ctx.db.transaction((tx) => + reclaimTransferredCharacter(tx, 1, { accountId: acc.id, ownerHash: "oh-stale" }), + ); + expect(r).toEqual({ ok: false, error: "changed" }); + expect(await getChar(1)).toBeDefined(); // nothing deleted + }); +}); From a4dbec987e32f6c67cf6321c52bf6da5105ddc8c Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 22:19:14 -0400 Subject: [PATCH 16/28] feat: purge job for sessions, oauth transactions, and dispatched outbox rows --- src/jobs/purge.ts | 45 +++++++++++++++++++++++++++++++++++++ tests/purge-job.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/jobs/purge.ts create mode 100644 tests/purge-job.test.ts diff --git a/src/jobs/purge.ts b/src/jobs/purge.ts new file mode 100644 index 00000000..383645af --- /dev/null +++ b/src/jobs/purge.ts @@ -0,0 +1,45 @@ +import { and, isNotNull, lt, or } from "drizzle-orm"; +import type { Db } from "@/db"; +import { oauthTransaction, outbox, session } from "@/db/schema"; +import { runJob, type JobResult } from "@/services/sync-run"; + +const OUTBOX_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +/** Carry-over hygiene: expired sessions, spent OAuth transactions, and old + * DISPATCHED outbox rows (undispatched rows are never purged). */ +export async function runPurgeJob(deps: { db: Db }): Promise { + const { db } = deps; + return runJob(db, "purge", async () => { + const now = new Date(); + const sessions = await db + .delete(session) + .where(lt(session.expiresAt, now)) + .returning({ id: session.id }); + const oauth = await db + .delete(oauthTransaction) + .where( + or( + isNotNull(oauthTransaction.consumedAt), + lt(oauthTransaction.expiresAt, now), + ), + ) + .returning({ id: oauthTransaction.id }); + const outboxRows = await db + .delete(outbox) + .where( + and( + isNotNull(outbox.dispatchedAt), + lt(outbox.createdAt, new Date(Date.now() - OUTBOX_RETENTION_MS)), + ), + ) + .returning({ id: outbox.id }); + return { + status: "ok", + counts: { + sessions: sessions.length, + oauthTransactions: oauth.length, + outbox: outboxRows.length, + }, + }; + }); +} diff --git a/tests/purge-job.test.ts b/tests/purge-job.test.ts new file mode 100644 index 00000000..c4910843 --- /dev/null +++ b/tests/purge-job.test.ts @@ -0,0 +1,49 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { oauthTransaction, outbox, session } from "@/db/schema"; +import { runPurgeJob } from "@/jobs/purge"; +import { setupTestDb } from "./helpers/db"; +import { seedAccount } from "./helpers/seed"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const DAY = 24 * 60 * 60 * 1000; + +describe("runPurgeJob", () => { + it("purges expired sessions, spent oauth transactions, and old dispatched outbox rows", async () => { + const acc = await seedAccount(ctx.db); + await ctx.db.insert(session).values([ + { id: "live", accountId: acc.id, expiresAt: new Date(Date.now() + DAY) }, + { id: "expired", accountId: acc.id, expiresAt: new Date(Date.now() - DAY) }, + ]); + await ctx.db.insert(oauthTransaction).values([ + { stateHash: "live", intent: "login", pkceVerifier: "v", expiresAt: new Date(Date.now() + DAY) }, + { stateHash: "expired", intent: "login", pkceVerifier: "v", expiresAt: new Date(Date.now() - DAY) }, + { stateHash: "consumed", intent: "login", pkceVerifier: "v", expiresAt: new Date(Date.now() + DAY), consumedAt: new Date() }, + ]); + await ctx.db.insert(outbox).values([ + { payload: { kind: "all" } }, // undispatched → NEVER purged + { payload: { kind: "all" }, dispatchedAt: new Date(), createdAt: new Date(Date.now() - 8 * DAY) }, + { payload: { kind: "all" }, dispatchedAt: new Date(), createdAt: new Date(Date.now() - DAY) }, + ]); + + const result = await runPurgeJob({ db: ctx.db }); + expect(result.status).toBe("ok"); + expect(result.counts).toEqual({ sessions: 1, oauthTransactions: 2, outbox: 1 }); + + expect((await ctx.db.select().from(session)).map((s) => s.id)).toEqual(["live"]); + expect((await ctx.db.select().from(oauthTransaction)).map((t) => t.stateHash)).toEqual(["live"]); + expect(await ctx.db.select().from(outbox)).toHaveLength(2); + }); +}); From a4e11dce2dae9b604a76c6cd9a5178ac71a3ad38 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 22:24:08 -0400 Subject: [PATCH 17/28] feat: transactional outbox dispatcher with singleton fan-out --- src/worker/dispatcher.ts | 114 +++++++++++++++++++++++++++++++++++++++ src/worker/queues.ts | 10 ++++ tests/dispatcher.test.ts | 99 ++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/worker/dispatcher.ts create mode 100644 src/worker/queues.ts create mode 100644 tests/dispatcher.test.ts diff --git a/src/worker/dispatcher.ts b/src/worker/dispatcher.ts new file mode 100644 index 00000000..c406559e --- /dev/null +++ b/src/worker/dispatcher.ts @@ -0,0 +1,114 @@ +import type { Db } from "@/db"; +import { markDispatched, takeUndispatched, type OutboxPayload } from "@/services/outbox"; +import { QUEUES } from "@/worker/queues"; + +export type QueueSend = ( + queue: string, + data: Record, + options: { singletonKey: string }, +) => Promise; + +/** + * Maps one outbox payload to its pg-boss sends. Membership and Discord roles + * are account-scopable; the desired contact/ACL sets are GLOBAL (every member + * pushes every other member), so account changes fan out to global + * reconciliations, coalesced by fixed singleton keys. Every payload carries + * jobType so the dead-letter handler can name the failed job. + */ +export function planDispatch( + payload: OutboxPayload, +): Array<{ queue: string; data: Record; singletonKey: string }> { + switch (payload.kind) { + case "account": + return [ + { + queue: QUEUES.membership, + data: { jobType: QUEUES.membership, accountId: payload.accountId }, + singletonKey: `membership:${payload.accountId}`, + }, + { + queue: QUEUES.contacts, + data: { jobType: QUEUES.contacts }, + singletonKey: "contacts:all", + }, + { + queue: QUEUES.wanderer, + data: { jobType: QUEUES.wanderer }, + singletonKey: "wanderer:all", + }, + { + queue: QUEUES.discordRoles, + data: { jobType: QUEUES.discordRoles, accountId: payload.accountId }, + singletonKey: `roles:${payload.accountId}`, + }, + ]; + case "discord-user": + return [ + { + queue: QUEUES.discordRoles, + data: { jobType: QUEUES.discordRoles, discordUserId: payload.discordUserId }, + singletonKey: `roles:user:${payload.discordUserId}`, + }, + ]; + case "all": + return [ + { + queue: QUEUES.membership, + data: { jobType: QUEUES.membership }, + singletonKey: "membership:all", + }, + { + queue: QUEUES.contacts, + data: { jobType: QUEUES.contacts }, + singletonKey: "contacts:all", + }, + { + queue: QUEUES.wanderer, + data: { jobType: QUEUES.wanderer }, + singletonKey: "wanderer:all", + }, + { + queue: QUEUES.discordRoles, + data: { jobType: QUEUES.discordRoles }, + singletonKey: "roles:all", + }, + ]; + } +} + +/** + * Claims undispatched rows and enqueues their jobs in ONE transaction (the + * takeUndispatched/markDispatched contract): a failed send rolls the claim + * back so rows are re-attempted next tick. FOR UPDATE SKIP LOCKED makes + * concurrent dispatchers safe without advisory locks. + */ +export async function dispatchOutbox(db: Db, send: QueueSend): Promise { + return db.transaction(async (tx) => { + const rows = await takeUndispatched(tx); + if (rows.length === 0) return 0; + for (const row of rows) { + for (const job of planDispatch(row.payload)) { + await send(job.queue, job.data, { singletonKey: job.singletonKey }); + } + } + await markDispatched( + tx, + rows.map((r) => r.id), + ); + return rows.length; + }); +} + +export function startDispatcher(db: Db, send: QueueSend, intervalMs = 2000): () => void { + let running = false; + const timer = setInterval(() => { + if (running) return; + running = true; + void dispatchOutbox(db, send) + .catch((err) => console.error("outbox dispatch failed", err)) + .finally(() => { + running = false; + }); + }, intervalMs); + return () => clearInterval(timer); +} diff --git a/src/worker/queues.ts b/src/worker/queues.ts new file mode 100644 index 00000000..7ca1b702 --- /dev/null +++ b/src/worker/queues.ts @@ -0,0 +1,10 @@ +export const QUEUES = { + membership: "membership", + membershipRecheck: "membership-recheck", + contacts: "contacts", + wanderer: "wanderer", + discordRoles: "discord-roles", + tokenHealth: "token-health", + purge: "purge", + deadLetter: "ops-dead-letter", +} as const; diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts new file mode 100644 index 00000000..bf2f16c0 --- /dev/null +++ b/tests/dispatcher.test.ts @@ -0,0 +1,99 @@ +import { isNull, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { outbox } from "@/db/schema"; +import { enqueueSync } from "@/services/outbox"; +import { dispatchOutbox, planDispatch } from "@/worker/dispatcher"; +import { setupTestDb } from "./helpers/db"; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +type Sent = { queue: string; data: Record; singletonKey: string }; +const collector = () => { + const sent: Sent[] = []; + const send = async ( + queue: string, + data: Record, + options: { singletonKey: string }, + ) => { + sent.push({ queue, data, singletonKey: options.singletonKey }); + }; + return { sent, send }; +}; + +describe("planDispatch", () => { + it("fans an account payload out to scoped membership/roles and GLOBAL contacts/wanderer", () => { + const plan = planDispatch({ kind: "account", accountId: "acc-1" }); + expect(plan.map((p) => p.queue).sort()).toEqual([ + "contacts", + "discord-roles", + "membership", + "wanderer", + ]); + const membership = plan.find((p) => p.queue === "membership"); + expect(membership?.data).toMatchObject({ accountId: "acc-1", jobType: "membership" }); + expect(membership?.singletonKey).toBe("membership:acc-1"); + // desired sets are global — contacts/wanderer coalesce on fixed keys + expect(plan.find((p) => p.queue === "contacts")?.singletonKey).toBe("contacts:all"); + expect(plan.find((p) => p.queue === "wanderer")?.singletonKey).toBe("wanderer:all"); + }); + + it("maps discord-user payloads to a role strip job", () => { + expect(planDispatch({ kind: "discord-user", discordUserId: "u9" })).toEqual([ + { + queue: "discord-roles", + data: { jobType: "discord-roles", discordUserId: "u9" }, + singletonKey: "roles:user:u9", + }, + ]); + }); + + it("maps 'all' to the four sync queues", () => { + expect(planDispatch({ kind: "all" }).map((p) => p.queue).sort()).toEqual([ + "contacts", + "discord-roles", + "membership", + "wanderer", + ]); + }); +}); + +describe("dispatchOutbox", () => { + it("sends and marks rows dispatched in one pass; second pass is a no-op", async () => { + await enqueueSync(ctx.db, { kind: "account", accountId: "acc-1" }); + await enqueueSync(ctx.db, { kind: "discord-user", discordUserId: "u9" }); + const { sent, send } = collector(); + expect(await dispatchOutbox(ctx.db, send)).toBe(2); + expect(sent).toHaveLength(5); // 4 fan-out + 1 role strip + const undispatched = await ctx.db + .select() + .from(outbox) + .where(isNull(outbox.dispatchedAt)); + expect(undispatched).toEqual([]); + expect(await dispatchOutbox(ctx.db, send)).toBe(0); + expect(sent).toHaveLength(5); + }); + + it("rolls the claim back when a send fails, so rows retry next tick", async () => { + await enqueueSync(ctx.db, { kind: "all" }); + const failingSend = async () => { + throw new Error("pg-boss unavailable"); + }; + await expect(dispatchOutbox(ctx.db, failingSend)).rejects.toThrow("pg-boss unavailable"); + const undispatched = await ctx.db + .select() + .from(outbox) + .where(isNull(outbox.dispatchedAt)); + expect(undispatched).toHaveLength(1); // still claimable + }); +}); From 2c35b06657c22fee1337616ab8e1da82a2565ad9 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 22:27:42 -0400 Subject: [PATCH 18/28] feat: pg-boss worker entry with schedules and dead-letter ops alerts --- package.json | 1 + src/worker/handlers.ts | 87 +++++++++++++++++++++++++++++++++++++ src/worker/index.ts | 65 +++++++++++++++++++++++++++ src/worker/queues.ts | 82 ++++++++++++++++++++++++++++++++++ tests/worker-queues.test.ts | 48 ++++++++++++++++++++ 5 files changed, 283 insertions(+) create mode 100644 src/worker/handlers.ts create mode 100644 src/worker/index.ts create mode 100644 tests/worker-queues.test.ts diff --git a/package.json b/package.json index 82dc529a..8b808ebc 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "worker": "tsx src/worker/index.ts", "test": "vitest run", "test:watch": "vitest", "db:generate": "drizzle-kit generate", diff --git a/src/worker/handlers.ts b/src/worker/handlers.ts new file mode 100644 index 00000000..30193bc2 --- /dev/null +++ b/src/worker/handlers.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import type { Config } from "@/config"; +import type { Db } from "@/db"; +import { runContactsJob, type ContactsEsi } from "@/jobs/contacts"; +import { runDiscordRolesJob } from "@/jobs/discord-roles"; +import { runMembershipJob } from "@/jobs/membership"; +import { runPurgeJob } from "@/jobs/purge"; +import { runTokenHealthJob } from "@/jobs/token-health"; +import { runWandererJob } from "@/jobs/wanderer"; +import type { DiscordClient } from "@/lib/discord/rest"; +import type { EsiClient } from "@/lib/esi/client"; +import type { WandererClient } from "@/lib/wanderer/client"; +import { QUEUES } from "@/worker/queues"; + +// Fail closed: every payload must carry the queue's literal jobType and no +// unknown fields — garbage never triggers a job (it rejects, retries, and +// surfaces via the dead-letter alert). +const membershipSchema = z + .object({ + jobType: z.literal(QUEUES.membership), + accountId: z.string().uuid().optional(), + }) + .strict(); +const membershipRecheckSchema = z + .object({ jobType: z.literal(QUEUES.membershipRecheck) }) + .strict(); +const contactsSchema = z.object({ jobType: z.literal(QUEUES.contacts) }).strict(); +const wandererSchema = z.object({ jobType: z.literal(QUEUES.wanderer) }).strict(); +const discordSchema = z + .object({ + jobType: z.literal(QUEUES.discordRoles), + accountId: z.string().uuid().optional(), + discordUserId: z.string().optional(), + }) + .strict(); +const tokenHealthSchema = z.object({ jobType: z.literal(QUEUES.tokenHealth) }).strict(); +const purgeSchema = z.object({ jobType: z.literal(QUEUES.purge) }).strict(); + +export type JobDeps = { + db: Db; + cfg: Config; + esi: Pick & ContactsEsi; + wanderer: WandererClient; + discord: DiscordClient; + fetchImpl?: typeof fetch; +}; + +/** + * One handler per job queue: parse the payload (fail closed — an unparseable + * payload throws and the job retries into the dead-letter alert) and run the + * job. The worker registers these with boss.work; tests drive them directly + * with dispatcher-emitted payloads, so routing and parsing stay covered. + */ +export function buildJobHandlers( + deps: JobDeps, +): Record Promise> { + return { + [QUEUES.membership]: async (data) => { + const { accountId } = membershipSchema.parse(data); + await runMembershipJob(deps, { accountId }); + }, + [QUEUES.membershipRecheck]: async (data) => { + membershipRecheckSchema.parse(data); + await runMembershipJob(deps, { recheckInvalid: true }); + }, + [QUEUES.contacts]: async (data) => { + contactsSchema.parse(data); + await runContactsJob(deps); + }, + [QUEUES.wanderer]: async (data) => { + wandererSchema.parse(data); + await runWandererJob(deps); + }, + [QUEUES.discordRoles]: async (data) => { + const { accountId, discordUserId } = discordSchema.parse(data); + await runDiscordRolesJob(deps, { accountId, discordUserId }); + }, + [QUEUES.tokenHealth]: async (data) => { + tokenHealthSchema.parse(data); + await runTokenHealthJob(deps); + }, + [QUEUES.purge]: async (data) => { + purgeSchema.parse(data); + await runPurgeJob(deps); + }, + }; +} diff --git a/src/worker/index.ts b/src/worker/index.ts new file mode 100644 index 00000000..2767aab7 --- /dev/null +++ b/src/worker/index.ts @@ -0,0 +1,65 @@ +import PgBoss from "pg-boss"; +import { z } from "zod"; +import { getConfig } from "@/config"; +import { createDb } from "@/db"; +import { createDiscordClient } from "@/lib/discord/rest"; +import { createEsiClient } from "@/lib/esi/client"; +import { postOpsWebhook } from "@/lib/ops-webhook"; +import { createWandererClient } from "@/lib/wanderer/client"; +import { startDispatcher } from "@/worker/dispatcher"; +import { buildJobHandlers } from "@/worker/handlers"; +import { QUEUES, createQueues, scheduleJobs } from "@/worker/queues"; + +const deadLetterSchema = z.object({ jobType: z.string().optional() }).nullish(); + +async function main(): Promise { + const cfg = getConfig(); + const { db, pool } = createDb(cfg.databaseUrl); + + const boss = new PgBoss({ connectionString: cfg.databaseUrl }); + boss.on("error", (err) => console.error("pg-boss error", err)); + await boss.start(); + await createQueues(boss); + + const handlers = buildJobHandlers({ + db, + cfg, + esi: createEsiClient(), + wanderer: createWandererClient(cfg), + discord: createDiscordClient(cfg), + }); + // pg-boss v10 handlers receive an ARRAY of jobs. + for (const [queue, handler] of Object.entries(handlers)) { + await boss.work(queue, async ([job]) => handler(job.data)); + } + + // Ops alerting (spec: Error handling): a job landing here exhausted its + // retries — post to the optional Discord ops webhook. + await boss.work(QUEUES.deadLetter, async ([job]) => { + const data = deadLetterSchema.parse(job.data); + await postOpsWebhook( + cfg, + `authGD: job \`${data?.jobType ?? "unknown"}\` failed after final retry.`, + ); + }); + + await scheduleJobs(boss); + const stopDispatcher = startDispatcher(db, (queue, data, options) => + boss.send(queue, data, options), + ); + + const shutdown = async (): Promise => { + stopDispatcher(); + await boss.stop({ graceful: true, wait: true }); + await pool.end(); + process.exit(0); + }; + process.on("SIGTERM", () => void shutdown()); + process.on("SIGINT", () => void shutdown()); + console.log("authGD worker started"); +} + +main().catch((err) => { + console.error("worker failed to start", err); + process.exit(1); +}); diff --git a/src/worker/queues.ts b/src/worker/queues.ts index 7ca1b702..4e773fae 100644 --- a/src/worker/queues.ts +++ b/src/worker/queues.ts @@ -1,3 +1,5 @@ +import type PgBoss from "pg-boss"; + export const QUEUES = { membership: "membership", membershipRecheck: "membership-recheck", @@ -8,3 +10,83 @@ export const QUEUES = { purge: "purge", deadLetter: "ops-dead-letter", } as const; + +/** ~5 tries over ~30 min: 60 s base delay with exponential backoff. */ +const RETRY = { retryLimit: 5, retryDelay: 60, retryBackoff: true }; + +const JOB_QUEUES = [ + QUEUES.membership, + QUEUES.membershipRecheck, + QUEUES.contacts, + QUEUES.wanderer, + QUEUES.discordRoles, + QUEUES.tokenHealth, + QUEUES.purge, +] as const; + +export async function createQueues(boss: PgBoss): Promise { + await boss.createQueue(QUEUES.deadLetter); + for (const name of JOB_QUEUES) { + // policy "short": singletonKey uniqueness only exists under this policy + // (pg-boss job_i1 partial index) — standard queues ignore singletonKey. + // Final-retry failures dead-letter into ops-dead-letter → ops webhook. + await boss.createQueue(name, { + name, + policy: "short", + ...RETRY, + deadLetter: QUEUES.deadLetter, + }); + } +} + +/** + * Spec schedules. pg-boss allows ONE schedule per queue, which is why the + * weekly affiliation_invalid recheck is its own queue. Hourly jobs are + * staggered to avoid stampeding shared integrations. + */ +export async function scheduleJobs(boss: PgBoss): Promise { + // Schedules share the dispatcher's global singleton keys so a scheduled + // tick and an on-demand global trigger coalesce instead of double-queueing. + await boss.schedule( + QUEUES.membership, + "*/30 * * * *", + { jobType: QUEUES.membership }, + { singletonKey: "membership:all" }, + ); + await boss.schedule( + QUEUES.membershipRecheck, + "0 4 * * 0", + { jobType: QUEUES.membershipRecheck }, + { singletonKey: "membership-recheck:all" }, + ); + await boss.schedule( + QUEUES.contacts, + "5 * * * *", + { jobType: QUEUES.contacts }, + { singletonKey: "contacts:all" }, + ); + await boss.schedule( + QUEUES.wanderer, + "10 * * * *", + { jobType: QUEUES.wanderer }, + { singletonKey: "wanderer:all" }, + ); + await boss.schedule( + QUEUES.discordRoles, + "15 * * * *", + { jobType: QUEUES.discordRoles }, + { singletonKey: "roles:all" }, + ); + await boss.schedule( + QUEUES.tokenHealth, + "0 3 * * *", + { jobType: QUEUES.tokenHealth }, + { singletonKey: "token-health:all" }, + ); + await boss.schedule( + QUEUES.purge, + "30 3 * * *", + { jobType: QUEUES.purge }, + { singletonKey: "purge:all" }, + ); +} diff --git a/tests/worker-queues.test.ts b/tests/worker-queues.test.ts new file mode 100644 index 00000000..1dc7110b --- /dev/null +++ b/tests/worker-queues.test.ts @@ -0,0 +1,48 @@ +import PgBoss from "pg-boss"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { QUEUES, createQueues, scheduleJobs } from "@/worker/queues"; + +const TEST_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://authgd:authgd@localhost:5433/authgd_test"; + +let boss: PgBoss; +beforeAll(async () => { + boss = new PgBoss({ connectionString: TEST_URL }); + boss.on("error", () => {}); + await boss.start(); + await createQueues(boss); +}); +afterAll(async () => { + await boss.stop({ graceful: false, wait: false }); +}); + +describe("worker queues", () => { + it("coalesces duplicate sends via singletonKey", async () => { + const key = `test-${Date.now()}`; // unique per run: pg-boss state persists + const first = await boss.send(QUEUES.contacts, { jobType: "contacts" }, { singletonKey: key }); + const second = await boss.send(QUEUES.contacts, { jobType: "contacts" }, { singletonKey: key }); + expect(first).not.toBeNull(); + expect(second).toBeNull(); // coalesced + }); + + it("applies one schedule per queue, each carrying its global singleton key", async () => { + await scheduleJobs(boss); + const schedules = await boss.getSchedules(); + const byName = new Map(schedules.map((s) => [s.name, s])); + expect(byName.get(QUEUES.membership)?.cron).toBe("*/30 * * * *"); + expect(byName.get(QUEUES.membershipRecheck)?.cron).toBe("0 4 * * 0"); + expect(byName.get(QUEUES.contacts)?.cron).toBe("5 * * * *"); + expect(byName.get(QUEUES.wanderer)?.cron).toBe("10 * * * *"); + expect(byName.get(QUEUES.discordRoles)?.cron).toBe("15 * * * *"); + expect(byName.get(QUEUES.tokenHealth)?.cron).toBe("0 3 * * *"); + expect(byName.get(QUEUES.purge)?.cron).toBe("30 3 * * *"); + // scheduled ticks coalesce with dispatcher-emitted global sends + expect(byName.get(QUEUES.contacts)?.options).toMatchObject({ + singletonKey: "contacts:all", + }); + expect(byName.get(QUEUES.wanderer)?.options).toMatchObject({ + singletonKey: "wanderer:all", + }); + }); +}); From ba4982f54ac1d08f645176d1ae97940adcb7919c Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:00:11 -0400 Subject: [PATCH 19/28] test: full deprovision-path integration coverage --- tests/deprovision-flow.test.ts | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 tests/deprovision-flow.test.ts diff --git a/tests/deprovision-flow.test.ts b/tests/deprovision-flow.test.ts new file mode 100644 index 00000000..d655026c --- /dev/null +++ b/tests/deprovision-flow.test.ts @@ -0,0 +1,170 @@ +import { sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; +import { auditLog, wandererAclObservation } from "@/db/schema"; +import type { DiscordClient } from "@/lib/discord/rest"; +import type { Affiliation } from "@/lib/esi/client"; +import type { WandererAclMember, WandererClient } from "@/lib/wanderer/client"; +import { dispatchOutbox } from "@/worker/dispatcher"; +import { buildJobHandlers, type JobDeps } from "@/worker/handlers"; +import { setupTestDb } from "./helpers/db"; +import { testConfig } from "./helpers/config"; +import { seedAccount, seedCharacter } from "./helpers/seed"; + +const cfg = testConfig(); +const LABEL_ID = 77; + +let ctx: Awaited>; +beforeAll(async () => { + ctx = await setupTestDb(); +}); +afterAll(() => ctx.cleanup()); +beforeEach(async () => { + await ctx.db.execute(sql` + TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, + outbox, oauth_transaction, contact_sync_state, sync_run, + wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE + `); +}); + +const okToken = (async () => + new Response( + JSON.stringify({ access_token: "at", refresh_token: "rt2" }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + +it("main leaves alliance → green → contacts removed, ACL removed, role changed, audited", async () => { + // leaver: flygd account with main (10) + alt (11), discord-linked + const leaver = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u-leaver" }); + await seedCharacter(ctx.db, cfg, { id: 10, accountId: leaver.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 11, accountId: leaver.id }); + // stayer: flygd account whose contacts currently include the leaver's chars + const stayer = await seedAccount(ctx.db, { tier: "flygd", discordUserId: "u-stayer" }); + await seedCharacter(ctx.db, cfg, { id: 20, accountId: stayer.id, main: true }); + + // --- fake integrations (same shapes as the Task 8–10 tests) --- + // ESI affiliation: leaver's main left the alliance; stayer's main is still in. + const contactWrites = { deletes: [] as number[][], adds: [] as number[][] }; + const esi: JobDeps["esi"] = { + postAffiliation: async (ids: number[]): Promise => + ids.map((id) => ({ + characterId: id, + corporationId: 1, + allianceId: id === 20 ? 99000001 : null, + })), + getContactLabels: async () => [{ labelId: LABEL_ID, labelName: "flygd" }], + // stayer's char 20 currently has 10 and 11 under our label + getAllContacts: async (characterId) => + characterId === 20 + ? [ + { contactId: 10, contactType: "character", standing: 5, labelIds: [LABEL_ID] }, + { contactId: 11, contactType: "character", standing: 5, labelIds: [LABEL_ID] }, + ] + : [], + addContacts: async (_c, _at, ids) => { + contactWrites.adds.push(ids); + }, + editContacts: async () => {}, + deleteContacts: async (_c, _at, ids) => { + contactWrites.deletes.push(ids); + }, + }; + + // Wanderer: the ACL still lists the leaver's chars. + let aclMembers: WandererAclMember[] = [ + { characterId: 10, role: "viewer" }, + { characterId: 11, role: "viewer" }, + { characterId: 20, role: "viewer" }, + ]; + const wanderer: WandererClient = { + getAclMembers: async () => [...aclMembers], + addAclMember: async (id) => { + aclMembers.push({ characterId: id, role: "viewer" }); + }, + updateAclMemberRole: async (id, role) => { + aclMembers = aclMembers.map((m) => (m.characterId === id ? { ...m, role } : m)); + }, + removeAclMember: async (id) => { + aclMembers = aclMembers.filter((m) => m.characterId !== id); + }, + }; + + // Discord: both users currently carry the FlyGD role. + const MANAGE_ROLES = String(1 << 28); + const roleOps = { added: [] as Array<[string, string]>, removed: [] as Array<[string, string]> }; + const memberRoles: Record = { + "u-leaver": ["10"], + "u-stayer": ["10"], + "bot-user": ["bot-role"], + }; + const discord: DiscordClient = { + getGuildRoles: async () => [ + { id: "10", name: "FlyGD", position: 5, permissions: "0" }, + { id: "11", name: "Blue", position: 4, permissions: "0" }, + { id: "12", name: "Green", position: 3, permissions: "0" }, + { id: "bot-role", name: "Bot", position: 9, permissions: MANAGE_ROLES }, + ], + getBotUserId: async () => "bot-user", + getGuildMember: async (userId) => + memberRoles[userId] ? { roles: memberRoles[userId] } : null, + addMemberRole: async (userId, roleId) => { + roleOps.added.push([userId, roleId]); + }, + removeMemberRole: async (userId, roleId) => { + roleOps.removed.push([userId, roleId]); + }, + }; + + // The REAL worker routing: every payload below goes through these handlers. + const handlers = buildJobHandlers({ + db: ctx.db, + cfg, + esi, + wanderer, + discord, + fetchImpl: okToken, + }); + + // 1) A scheduled membership run demotes the leaver (green + outbox row). + await handlers["membership"]({ jobType: "membership" }); + + // 2) The demotion's outbox row fans out through the real dispatcher… + const sent: Array<{ queue: string; data: Record }> = []; + const dispatched = await dispatchOutbox(ctx.db, async (queue, data) => { + sent.push({ queue, data }); + }); + expect(dispatched).toBeGreaterThanOrEqual(1); + expect(new Set(sent.map((s) => s.queue))).toEqual( + new Set(["membership", "contacts", "wanderer", "discord-roles"]), + ); + + // 3) …and every emitted payload is consumed by the real worker routing + // (payload parsing + queue → job wiring), not by manual job calls. + for (const msg of sent) { + const handler = handlers[msg.queue]; + expect(handler, `no handler for queue ${msg.queue}`).toBeDefined(); + await handler(msg.data); + } + + // 4) Automatic removal (req. 3): leaver's chars deleted from 20's contacts. + expect(contactWrites.deletes).toContainEqual([10, 11]); + + // 5) Wanderer: leaver's chars removed; observation is the post-mutation read. + const observed = await ctx.db.select().from(wandererAclObservation); + expect(observed.map((o) => o.characterId)).toEqual([20]); + + // 6) Discord: leaver ends with EXACTLY green; stayer untouched (the fan-out + // was scoped to the demoted account). + expect(roleOps.added).toContainEqual(["u-leaver", "12"]); + expect(roleOps.removed).toContainEqual(["u-leaver", "10"]); + expect(roleOps.added).not.toContainEqual(["u-stayer", "12"]); + + // 7) Audit trail: demotion cause + downstream actions all recorded. + const audits = await ctx.db.select().from(auditLog); + const tierChange = audits.find((a) => a.action === "tier.changed"); + expect(tierChange?.details).toMatchObject({ to: "green", cause: "main left alliance" }); + expect(audits.filter((a) => a.action === "wanderer.removed")).toHaveLength(2); + expect(audits.some((a) => a.action === "discord.role_changed")).toBe(true); + + // 8) Fail-closed routing: garbage payloads reject instead of running a job. + await expect(handlers["membership"]({ garbage: true })).rejects.toThrow(); +}); From b6e779abb340dd47e9b5f49c7d187864e6d72caf Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:15:57 -0400 Subject: [PATCH 20/28] fix: never remove blocked ACL entries in diffAcl Removing a non-desired blocked member restored access via any inert corp/alliance ACL entry, effectively un-banning them. Blocked access must only ever be lifted deliberately via unblock. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- src/core/acl-diff.ts | 9 +++++++-- tests/acl-diff.test.ts | 14 +++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/core/acl-diff.ts b/src/core/acl-diff.ts index 2895ad72..3f3c4e5f 100644 --- a/src/core/acl-diff.ts +++ b/src/core/acl-diff.ts @@ -5,7 +5,10 @@ export type AclMember = { characterId: number; role: string }; * removed like anyone else when they leave the desired set. A desired * character whose role is "blocked" has no effective access — presence alone * is not convergence — so it is unblocked (reset to viewer); all other roles - * (admin/manager/member/viewer) are preserved as-is. + * (admin/manager/member/viewer) are preserved as-is. Blocked entries are also + * NEVER removed even when undesired: removing a blocked member is equivalent + * to un-banning them, which could restore access via an inert corp/alliance + * ACL entry — blocked access must only ever be lifted deliberately. */ export function diffAcl(input: { desiredIds: number[]; members: AclMember[] }): { add: number[]; @@ -18,7 +21,9 @@ export function diffAcl(input: { desiredIds: number[]; members: AclMember[] }): add: input.desiredIds.filter((id) => !byId.has(id)), unblock: input.desiredIds.filter((id) => byId.get(id)?.role === "blocked"), remove: input.members - .filter((m) => !desired.has(m.characterId) && m.role !== "admin") + .filter( + (m) => !desired.has(m.characterId) && m.role !== "admin" && m.role !== "blocked", + ) .map((m) => m.characterId), }; } diff --git a/tests/acl-diff.test.ts b/tests/acl-diff.test.ts index 74fa5982..f7317641 100644 --- a/tests/acl-diff.test.ts +++ b/tests/acl-diff.test.ts @@ -36,12 +36,24 @@ describe("diffAcl", () => { { characterId: 2, role: "manager" }, // elevated → preserved { characterId: 3, role: "viewer" }, // normal → preserved { characterId: 4, role: "admin" }, // elevated → preserved - { characterId: 5, role: "blocked" }, // blocked AND undesired → removed + { characterId: 5, role: "member" }, // undesired, normal role → removed ], }), ).toEqual({ add: [], remove: [5], unblock: [1] }); }); + it("NEVER removes blocked entries, even when undesired — removal would un-ban them", () => { + expect( + diffAcl({ + desiredIds: [], + members: [ + { characterId: 5, role: "blocked" }, + { characterId: 6, role: "member" }, + ], + }), + ).toEqual({ add: [], remove: [6], unblock: [] }); + }); + it("is a no-op when converged", () => { expect( diffAcl({ desiredIds: [1], members: [{ characterId: 1, role: "member" }] }), From 175337f85a0cba5fb9110358d891622609b261b8 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:16:43 -0400 Subject: [PATCH 21/28] fix: contain JWT verification failures in token-health job verifyEveAccessToken ran outside any try/catch, so one character with a bad token (missing claim, malformed subject) threw and aborted the loop, permanently skipping every later character. Now a deterministic EveSsoError marks that character invalid (guarded on tokenEnc, like the existing subject-mismatch path) and continues; anything else (JWKS/network trouble) counts as transient with no state change. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- src/jobs/token-health.ts | 37 +++++++++++++++++++-- tests/token-health-job.test.ts | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/jobs/token-health.ts b/src/jobs/token-health.ts index 92dc4df9..5c967ddd 100644 --- a/src/jobs/token-health.ts +++ b/src/jobs/token-health.ts @@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"; import type { Config } from "@/config"; import type { Db } from "@/db"; import { character } from "@/db/schema"; -import { verifyEveAccessToken } from "@/lib/esi/sso"; +import { EveSsoError, verifyEveAccessToken } from "@/lib/esi/sso"; import { reclaimTransferredCharacter } from "@/services/accounts"; import { logAudit } from "@/services/audit"; import { runJob, type JobResult } from "@/services/sync-run"; @@ -30,7 +30,40 @@ export async function runTokenHealthJob(deps: { else counts.invalid++; // permanent-only invalidation done in the service continue; } - const identity = await verifyEveAccessToken(token.accessToken); + // A permanently failed token never blocks the rest of a sync: a + // deterministic verify failure (bad/missing claims, malformed subject) + // marks this character and moves on; transient trouble (JWKS fetch, + // network) leaves state untouched and counts as transient so the run + // retries without permanently invalidating anything. + let identity; + try { + identity = await verifyEveAccessToken(token.accessToken); + } catch (err) { + if (err instanceof EveSsoError) { + const applied = await db.transaction(async (tx) => { + const rows = await tx + .update(character) + .set({ tokenStatus: "invalid" }) + .where( + and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), + ) + .returning({ id: character.id }); + if (rows.length === 0) return false; + await logAudit(tx, { + actor: "system", + action: "token.verify_failed", + target: String(ch.id), + details: { error: err.message }, + }); + return true; + }); + if (applied) counts.invalid++; + else transientFailures++; + } else { + transientFailures++; + } + continue; + } if (identity.characterId !== ch.id) { // Fail closed: a token whose subject is another character must never diff --git a/tests/token-health-job.test.ts b/tests/token-health-job.test.ts index dd7c6db2..394234ea 100644 --- a/tests/token-health-job.test.ts +++ b/tests/token-health-job.test.ts @@ -75,6 +75,21 @@ function refreshFetchFor(accessTokens: Record): typeof fetch { }) as typeof fetch; } +/** Signs a structurally valid EVE JWT missing the `owner` claim — verifyEveAccessToken + * throws EveSsoError("EVE JWT missing owner claim") for this, unlike signAccessToken. */ +async function signTokenMissingOwnerClaim(opts: { + characterId: number; + scopes: string[]; +}): Promise { + return new SignJWT({ name: "Pilot", scp: opts.scopes }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("https://login.eveonline.com") + .setAudience("EVE Online") + .setSubject(`CHARACTER:EVE:${opts.characterId}`) + .setExpirationTime("5m") + .sign(privateKey); +} + async function getChar(id: number) { const rows = await ctx.db.select().from(character).where(eq(character.id, id)); return rows[0]; @@ -214,6 +229,52 @@ describe("runTokenHealthJob", () => { expect(audits.some((a) => a.action === "token.subject_mismatch")).toBe(true); }); + it("a verify failure on one character never blocks the rest of the run", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + await seedCharacter(ctx.db, cfg, { + id: 2, accountId: acc.id, refreshToken: "rt2", ownerHash: "oh-2", + }); + const badAt = await signTokenMissingOwnerClaim({ + characterId: 1, scopes: [...cfg.eveSso.scopes], + }); + const goodAt = await signAccessToken({ + characterId: 2, ownerHash: "oh-2", scopes: [...cfg.eveSso.scopes], + }); + const result = await runTokenHealthJob({ + db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: badAt, rt2: goodAt }), + }); + expect(result.status).toBe("ok"); + expect(result.counts).toMatchObject({ invalid: 1, refreshed: 1 }); + expect((await getChar(1)).tokenStatus).toBe("invalid"); + expect((await getChar(2)).tokenStatus).toBe("valid"); // character 2 still processed + const audits = await ctx.db.select().from(auditLog); + expect(audits.some((a) => a.action === "token.verify_failed")).toBe(true); + }); + + it("a transient verify failure (JWKS/network trouble) changes nothing and retries", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { + id: 1, accountId: acc.id, main: true, refreshToken: "rt1", ownerHash: "oh-1", + }); + // Signed with a DIFFERENT key than the one verifyEveAccessToken checks + // against — jose throws a signature-verification error, not EveSsoError. + const otherPair = await generateKeyPair("RS256"); + const at = await new SignJWT({ name: "Pilot", owner: "oh-1", scp: [...cfg.eveSso.scopes] }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer("https://login.eveonline.com") + .setAudience("EVE Online") + .setSubject("CHARACTER:EVE:1") + .setExpirationTime("5m") + .sign(otherPair.privateKey); + await expect( + runTokenHealthJob({ db: ctx.db, cfg, fetchImpl: refreshFetchFor({ rt1: at }) }), + ).rejects.toBeInstanceOf(JobRetryError); + expect((await getChar(1)).tokenStatus).toBe("valid"); // unchanged + }); + it("reclaimTransferredCharacter refuses a stale decision (row changed hands)", async () => { const acc = await seedAccount(ctx.db, { tier: "flygd" }); // the row's CURRENT owner hash is already the new owner's From 70233f8e25deda22362e9693237e8cf3032e6b2b Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:17:57 -0400 Subject: [PATCH 22/28] fix: alert ops webhook on permanent wanderer ACL-read failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-retry failed branch returned silently, so pg-boss saw a handled job and never dead-lettered it — a rotated API key would cause a silent, permanent outage. Now mirrors discord-roles.ts's permanent-config path: posts to the ops webhook before returning. Threaded cfg (and optional fetchImpl for tests) into runWandererJob's deps. Transient read failures still throw and retry without alerting. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- src/jobs/wanderer.ts | 20 +++++++++---- tests/wanderer-job.test.ts | 58 ++++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/jobs/wanderer.ts b/src/jobs/wanderer.ts index 5a917545..5fa6ba14 100644 --- a/src/jobs/wanderer.ts +++ b/src/jobs/wanderer.ts @@ -1,7 +1,9 @@ +import type { Config } from "@/config"; import type { Db } from "@/db"; import { wandererAclObservation } from "@/db/schema"; import { diffAcl } from "@/core/acl-diff"; import { WandererError, type WandererClient } from "@/lib/wanderer/client"; +import { postOpsWebhook } from "@/lib/ops-webhook"; import { logAudit } from "@/services/audit"; import { getFlygdCharacters } from "@/services/desired"; import { runJob, type JobResult } from "@/services/sync-run"; @@ -22,9 +24,11 @@ const isTransient = (err: unknown): boolean => export async function runWandererJob(deps: { db: Db; + cfg: Config; wanderer: WandererClient; + fetchImpl?: typeof fetch; }): Promise { - const { db, wanderer } = deps; + const { db, cfg, wanderer } = deps; return runJob(db, "wanderer", async () => { const desiredIds = (await getFlygdCharacters(db)).map((c) => c.characterId); @@ -33,11 +37,15 @@ export async function runWandererJob(deps: { try { members = await wanderer.getAclMembers(); } catch (err) { - return { - status: "failed", - errorSummary: `ACL read failed: ${err instanceof Error ? err.message : String(err)}`, - ...(isTransient(err) ? { retry: true } : {}), - }; + const msg = `ACL read failed: ${err instanceof Error ? err.message : String(err)}`; + if (isTransient(err)) { + return { status: "failed", errorSummary: msg, retry: true }; + } + // Permanent (e.g. rotated API key): this is otherwise a silent, + // permanent outage — pg-boss sees a returned "failed" as handled and + // never dead-letters it, so alert directly. + await postOpsWebhook(cfg, `authGD: wanderer ${msg}`, deps.fetchImpl); + return { status: "failed", errorSummary: msg }; } const diff = diffAcl({ desiredIds, members: characterEntries(members) }); diff --git a/tests/wanderer-job.test.ts b/tests/wanderer-job.test.ts index 0962cf39..a44fd246 100644 --- a/tests/wanderer-job.test.ts +++ b/tests/wanderer-job.test.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { auditLog, wandererAclObservation } from "@/db/schema"; import { runWandererJob } from "@/jobs/wanderer"; import { @@ -32,6 +32,8 @@ type Member = WandererAclMember; /** Fake Wanderer with a mutable member list and scriptable failures. */ function fakeWanderer(initial: Member[], opts: { failFirstRead?: boolean; + /** Initial read failure is permanent (e.g. rotated API key), not transient. */ + permanentFirstReadFailure?: boolean; failReRead?: boolean; failRemoveOf?: number; /** When set with failRemoveOf, the remove failure is permanent (transient: false). */ @@ -45,6 +47,9 @@ function fakeWanderer(initial: Member[], opts: { if (opts.failFirstRead && reads === 1) { throw new WandererError("read failed", { status: 502, transient: true }); } + if (opts.permanentFirstReadFailure && reads === 1) { + throw new WandererError("read failed", { status: 401, transient: false }); + } if (opts.failReRead && reads > 1) { throw new WandererError("re-read failed", { status: 502, transient: true }); } @@ -83,7 +88,7 @@ describe("runWandererJob", () => { { characterId: 4, role: "manager" }, { characterId: null, role: "viewer" }, // corp/alliance entry — never touched ]); - const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + const result = await runWandererJob({ db: ctx.db, cfg, wanderer: w.client }); expect(result.status).toBe("ok"); expect(result.counts).toMatchObject({ added: 1, removed: 2 }); expect(w.reads()).toBe(2); // initial + post-mutation @@ -104,16 +109,53 @@ describe("runWandererJob", () => { await seedFlygdChar(1); const w = fakeWanderer([{ characterId: 2, role: "member" }], { failFirstRead: true }); await expect( - runWandererJob({ db: ctx.db, wanderer: w.client }), + runWandererJob({ db: ctx.db, cfg, wanderer: w.client }), ).rejects.toBeInstanceOf(JobRetryError); expect(w.members()).toEqual([{ characterId: 2, role: "member" }]); // untouched expect(await ctx.db.select().from(wandererAclObservation)).toEqual([]); }); + it("alerts the ops webhook on a PERMANENT initial read failure (e.g. rotated API key)", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 2, role: "member" }], { + permanentFirstReadFailure: true, + }); + const webhook = vi.fn(async () => new Response("", { status: 200 })); + const result = await runWandererJob({ + db: ctx.db, + cfg, + wanderer: w.client, + fetchImpl: webhook as unknown as typeof fetch, + }); + // returned, not thrown: a permanent read failure must not retry-loop — + // but pg-boss would otherwise see success and never dead-letter it, so + // the ops webhook is the only alert path. + expect(result.status).toBe("failed"); + expect(result.retry).toBeUndefined(); + expect(webhook).toHaveBeenCalledOnce(); + const [, init] = webhook.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string).content).toContain("wanderer"); + }); + + it("does NOT alert the ops webhook on a transient initial read failure", async () => { + await seedFlygdChar(1); + const w = fakeWanderer([{ characterId: 2, role: "member" }], { failFirstRead: true }); + const webhook = vi.fn(async () => new Response("", { status: 200 })); + await expect( + runWandererJob({ + db: ctx.db, + cfg, + wanderer: w.client, + fetchImpl: webhook as unknown as typeof fetch, + }), + ).rejects.toBeInstanceOf(JobRetryError); + expect(webhook).not.toHaveBeenCalled(); + }); + it("persists the initial read as the observation when nothing needs mutating", async () => { await seedFlygdChar(1); const w = fakeWanderer([{ characterId: 1, role: "member" }]); - await runWandererJob({ db: ctx.db, wanderer: w.client }); + await runWandererJob({ db: ctx.db, cfg, wanderer: w.client }); expect(w.reads()).toBe(1); const observed = await ctx.db.select().from(wandererAclObservation); expect(observed).toHaveLength(1); @@ -130,7 +172,7 @@ describe("runWandererJob", () => { { failRemoveOf: 5 }, ); await expect( - runWandererJob({ db: ctx.db, wanderer: w.client }), + runWandererJob({ db: ctx.db, cfg, wanderer: w.client }), ).rejects.toBeInstanceOf(JobRetryError); const observed = await ctx.db.select().from(wandererAclObservation); // 5's removal failed, so the post-mutation read still contains it — and @@ -141,7 +183,7 @@ describe("runWandererJob", () => { it("unblocks a desired blocked member and observes the new role", async () => { await seedFlygdChar(1); const w = fakeWanderer([{ characterId: 1, role: "blocked" }]); - const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + const result = await runWandererJob({ db: ctx.db, cfg, wanderer: w.client }); expect(result.status).toBe("ok"); expect(result.counts).toMatchObject({ unblocked: 1, added: 0, removed: 0 }); const observed = await ctx.db.select().from(wandererAclObservation); @@ -161,7 +203,7 @@ describe("runWandererJob", () => { { failRemoveOf: 5, permanentRemoveFailure: true }, ); // returned, not thrown: permanent failures must not retry-loop - const result = await runWandererJob({ db: ctx.db, wanderer: w.client }); + const result = await runWandererJob({ db: ctx.db, cfg, wanderer: w.client }); expect(result.status).toBe("partial"); expect(result.retry).toBeUndefined(); }); @@ -175,7 +217,7 @@ describe("runWandererJob", () => { }); const w = fakeWanderer([{ characterId: 2, role: "member" }], { failReRead: true }); await expect( - runWandererJob({ db: ctx.db, wanderer: w.client }), + runWandererJob({ db: ctx.db, cfg, wanderer: w.client }), ).rejects.toBeInstanceOf(JobRetryError); const observed = await ctx.db.select().from(wandererAclObservation); expect(observed.map((o) => o.characterId)).toEqual([42]); // stale but honest From b7be555f83f825fad270bc8d5398cac29ed8bd4a Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:19:10 -0400 Subject: [PATCH 23/28] fix: isolate one unpushable desired contact from halting removals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A biomassed (affiliation_invalid) character stayed in the desired set and ESI 400-rejected it during addContacts; the shared try/catch then skipped both edits and deletes for every remaining target in the run. Fixed on both sides: getFlygdCharacters now excludes affiliation_invalid characters (they can't be valid contact targets or ids), and the delete step runs in its own try/catch per character so removals survive a failed add/edit — the add/edit failure is still classified and recorded as before. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- src/jobs/contacts.ts | 77 +++++++++++++++++++++++--------------- src/services/desired.ts | 8 +++- tests/contacts-job.test.ts | 23 ++++++++++++ tests/desired.test.ts | 10 +++++ 4 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/jobs/contacts.ts b/src/jobs/contacts.ts index b2df1b46..ecbaaf07 100644 --- a/src/jobs/contacts.ts +++ b/src/jobs/contacts.ts @@ -116,39 +116,54 @@ export async function runContactsJob(deps: { labelId: label.labelId, contacts, }); - if (diff.add.length > 0) { - await esi.addContacts( - target.characterId, - token.accessToken, - diff.add, - cfg.standings.value, - [label.labelId], - ); + // Add/edit failures (e.g. ESI 400-rejecting a since-biomassed desired + // id) must not block removal: removals are tried separately below, + // regardless of whether this step failed. + let stepErr: unknown = null; + try { + if (diff.add.length > 0) { + await esi.addContacts( + target.characterId, + token.accessToken, + diff.add, + cfg.standings.value, + [label.labelId], + ); + } + // Group takeovers by their preserved label set — PUT replaces + // label_ids wholesale, so each distinct union is its own call. + const groups = new Map(); + for (const u of diff.update) { + const key = u.labelIds.join(","); + const g = groups.get(key) ?? { labelIds: u.labelIds, ids: [] }; + g.ids.push(u.contactId); + groups.set(key, g); + } + for (const g of groups.values()) { + await esi.editContacts( + target.characterId, + token.accessToken, + g.ids, + cfg.standings.value, + g.labelIds, + ); + } + counts.added += diff.add.length; + counts.updated += diff.update.length; + } catch (err) { + stepErr = err; } - // Group takeovers by their preserved label set — PUT replaces - // label_ids wholesale, so each distinct union is its own call. - const groups = new Map(); - for (const u of diff.update) { - const key = u.labelIds.join(","); - const g = groups.get(key) ?? { labelIds: u.labelIds, ids: [] }; - g.ids.push(u.contactId); - groups.set(key, g); - } - for (const g of groups.values()) { - await esi.editContacts( - target.characterId, - token.accessToken, - g.ids, - cfg.standings.value, - g.labelIds, - ); - } - if (diff.remove.length > 0) { - await esi.deleteContacts(target.characterId, token.accessToken, diff.remove); + + try { + if (diff.remove.length > 0) { + await esi.deleteContacts(target.characterId, token.accessToken, diff.remove); + } + counts.removed += diff.remove.length; + } catch (err) { + stepErr ??= err; // report the add/edit failure first if both failed } - counts.added += diff.add.length; - counts.updated += diff.update.length; - counts.removed += diff.remove.length; + + if (stepErr) throw stepErr; await recordResult(db, target.characterId, "ok", true); } catch (err) { const needsReauth = err instanceof EsiError && err.kind === "needs_reauth"; diff --git a/src/services/desired.ts b/src/services/desired.ts index 6b42a5c8..8b73f73b 100644 --- a/src/services/desired.ts +++ b/src/services/desired.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { Dbx } from "@/db"; import { account, character } from "@/db/schema"; @@ -14,6 +14,10 @@ export type FlygdCharacter = { /** * The derived desired set: every character of every FlyGD account (spec: Data * model → Derived). Green/Blue accounts simply fall out; nothing is deleted. + * A character with affiliation_invalid (biomassed/deleted at CCP) is excluded: + * it can't be a valid contact target or ACL member, and ESI rejects it — + * leaving it in would permanently poison every downstream sync that shares + * this desired set. */ export async function getFlygdCharacters(dbx: Dbx): Promise { return dbx @@ -27,5 +31,5 @@ export async function getFlygdCharacters(dbx: Dbx): Promise { }) .from(character) .innerJoin(account, eq(character.accountId, account.id)) - .where(eq(account.tier, "flygd")); + .where(and(eq(account.tier, "flygd"), eq(character.affiliationInvalid, false))); } diff --git a/tests/contacts-job.test.ts b/tests/contacts-job.test.ts index 68addc40..209a0193 100644 --- a/tests/contacts-job.test.ts +++ b/tests/contacts-job.test.ts @@ -200,6 +200,29 @@ describe("runContactsJob", () => { expect(calls.adds).toContainEqual({ characterId: 1, ids: [2], labelIds: [LABEL_ID] }); }); + it("still deletes stale contacts when addContacts permanently fails on another id", async () => { + const acc = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); + await seedCharacter(ctx.db, cfg, { id: 2, accountId: acc.id }); + const { esi, calls } = fakeEsi({ + contacts: { + 1: [labeled(99)], // ours, no longer desired → delete + }, + }); + const failingEsi: ContactsEsi = { + ...esi, + addContacts: async () => { + throw new EsiError("invalid contact id", 400, "permanent"); + }, + }; + const result = await runContactsJob({ db: ctx.db, cfg, esi: failingEsi, fetchImpl: okToken }); + expect(result.status).toBe("partial"); + expect(result.counts.failed).toBeGreaterThan(0); + // the add failed permanently, but the delete still ran + expect(calls.deletes).toContainEqual({ characterId: 1, ids: [99] }); + expect((await lastResult(1))?.lastResult).toBe("sync_failed"); + }); + it("marks the character needs_reauth when ESI rejects the scope", async () => { const acc = await seedAccount(ctx.db, { tier: "flygd" }); await seedCharacter(ctx.db, cfg, { id: 1, accountId: acc.id, main: true }); diff --git a/tests/desired.test.ts b/tests/desired.test.ts index b27bd36a..aebe62dc 100644 --- a/tests/desired.test.ts +++ b/tests/desired.test.ts @@ -33,4 +33,14 @@ describe("getFlygdCharacters", () => { expect(rows.map((r) => r.characterId).sort((a, b) => a - b)).toEqual([1, 2]); expect(rows[0]).toMatchObject({ accountId: flygd.id, tokenStatus: "valid" }); }); + + it("excludes affiliation_invalid characters — they can't be contact targets or ACL members", async () => { + const flygd = await seedAccount(ctx.db, { tier: "flygd" }); + await seedCharacter(ctx.db, cfg, { id: 1, accountId: flygd.id, main: true }); + await seedCharacter(ctx.db, cfg, { + id: 2, accountId: flygd.id, affiliationInvalid: true, + }); + const rows = await getFlygdCharacters(ctx.db); + expect(rows.map((r) => r.characterId)).toEqual([1]); + }); }); From 09f8f8c03b682d560241a8ba49e27580dba8e374 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:21:27 -0400 Subject: [PATCH 24/28] fix: satisfy strict typecheck in finding 2/3 test additions Optional counts field needed an optional-chain access, and the mocked fetch needed explicit parameter types for the mock.calls tuple. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- tests/contacts-job.test.ts | 2 +- tests/wanderer-job.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/contacts-job.test.ts b/tests/contacts-job.test.ts index 209a0193..30a3cf6e 100644 --- a/tests/contacts-job.test.ts +++ b/tests/contacts-job.test.ts @@ -217,7 +217,7 @@ describe("runContactsJob", () => { }; const result = await runContactsJob({ db: ctx.db, cfg, esi: failingEsi, fetchImpl: okToken }); expect(result.status).toBe("partial"); - expect(result.counts.failed).toBeGreaterThan(0); + expect(result.counts?.failed).toBeGreaterThan(0); // the add failed permanently, but the delete still ran expect(calls.deletes).toContainEqual({ characterId: 1, ids: [99] }); expect((await lastResult(1))?.lastResult).toBe("sync_failed"); diff --git a/tests/wanderer-job.test.ts b/tests/wanderer-job.test.ts index a44fd246..140844bb 100644 --- a/tests/wanderer-job.test.ts +++ b/tests/wanderer-job.test.ts @@ -120,7 +120,7 @@ describe("runWandererJob", () => { const w = fakeWanderer([{ characterId: 2, role: "member" }], { permanentFirstReadFailure: true, }); - const webhook = vi.fn(async () => new Response("", { status: 200 })); + const webhook = vi.fn(async (_url: string, _init: RequestInit) => new Response("", { status: 200 })); const result = await runWandererJob({ db: ctx.db, cfg, @@ -133,7 +133,7 @@ describe("runWandererJob", () => { expect(result.status).toBe("failed"); expect(result.retry).toBeUndefined(); expect(webhook).toHaveBeenCalledOnce(); - const [, init] = webhook.mock.calls[0] as [string, RequestInit]; + const [, init] = webhook.mock.calls[0]; expect(JSON.parse(init.body as string).content).toContain("wanderer"); }); From ca52eb0d1d69cb8df4f228b12dbd8b06846c1d89 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:48:21 -0400 Subject: [PATCH 25/28] fix: harden chunk/role-diff/contacts/purge/tokens/rest/esi/wanderer Adjudicated CodeRabbit findings on PR #2: - chunk(): reject non-positive-integer size before looping. - validateRoleConfig: fold @everyone's guild role into the bot's permission union (Discord omits it from member role arrays), and parse each role's permissions defensively so a malformed value counts as no permissions instead of throwing. - contacts.ts update-grouping: sort each update's labelIds before building the group key/value so identical label sets in different orders share one editContacts call. - discord-roles.ts strip path: classify DiscordApiError like the account loop (permanent -> failed, no retry; anything else rethrows for pg-boss retry) instead of leaving Discord calls unguarded. - purge.ts: reuse the single captured `now`, anchor outbox retention on dispatchedAt instead of createdAt, and use rowCount from each delete instead of a wasted RETURNING. - tokens.ts: generalize the private CAS-invalidate helper into an exported invalidateTokenIfUnchanged(db, characterId, expectedEnc, audit) so token-health.ts's two duplicated transactions can reuse it. - discord/rest.ts: wrap the three zod parses so malformed bodies throw a permanent DiscordApiError instead of an unclassified exception. - esi/client.ts: only assign remain/resetAt when the parsed rate-limit headers are finite, preserving previous values otherwise. - wanderer/client.ts eveIdSchema: refine to a positive safe integer so an oversized id string fails the whole ACL read closed. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- src/core/chunk.ts | 3 +++ src/core/role-diff.ts | 20 +++++++++++++++++-- src/jobs/contacts.ts | 5 +++-- src/jobs/discord-roles.ts | 25 +++++++++++++++++------- src/jobs/purge.ts | 19 +++++++----------- src/jobs/token-health.ts | 40 +++++++------------------------------- src/lib/discord/rest.ts | 23 +++++++++++++++++----- src/lib/esi/client.ts | 10 ++++++++-- src/lib/wanderer/client.ts | 6 +++++- src/services/tokens.ts | 23 +++++++++++----------- 10 files changed, 99 insertions(+), 75 deletions(-) diff --git a/src/core/chunk.ts b/src/core/chunk.ts index 5a5c4693..eb721c86 100644 --- a/src/core/chunk.ts +++ b/src/core/chunk.ts @@ -1,4 +1,7 @@ export function chunk(items: T[], size: number): T[][] { + if (!Number.isInteger(size) || size <= 0) { + throw new Error("chunk: size must be a positive integer"); + } const out: T[][] = []; for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); return out; diff --git a/src/core/role-diff.ts b/src/core/role-diff.ts index e5cb85a2..d0150ad6 100644 --- a/src/core/role-diff.ts +++ b/src/core/role-diff.ts @@ -32,10 +32,23 @@ const ADMINISTRATOR = 1n << 3n; * bot has Manage Roles (or Administrator); bot's highest role sits ABOVE * every managed role. Failure is permanent-config — no retry loop. */ +/** Malformed permissions strings must never grant access — treat as zero. */ +function parsePermissions(permissions: string): bigint { + try { + const n = BigInt(permissions); + return n < 0n ? 0n : n; + } catch { + return 0n; + } +} + export function validateRoleConfig(input: { managed: ManagedRoleIds; guildRoles: Array<{ id: string; position: number; permissions: string }>; botRoleIds: string[]; + /** Discord omits @everyone (id === guild id) from member role arrays; when + * provided, its guild role is folded into the bot's permission union. */ + everyoneRoleId?: string; }): { ok: true } | { ok: false; error: string } { const ids = [input.managed.flygd, input.managed.blue, input.managed.green]; if (new Set(ids).size !== 3) { @@ -46,12 +59,15 @@ export function validateRoleConfig(input: { if (missing.length > 0) { return { ok: false, error: `managed roles missing from guild: ${missing.join(", ")}` }; } - const botRoles = input.botRoleIds.flatMap((id) => { + const botRoleIds = input.everyoneRoleId + ? [...new Set([...input.botRoleIds, input.everyoneRoleId])] + : input.botRoleIds; + const botRoles = botRoleIds.flatMap((id) => { const role = byId.get(id); return role ? [role] : []; }); const canManage = botRoles.some( - (r) => (BigInt(r.permissions) & (MANAGE_ROLES | ADMINISTRATOR)) !== 0n, + (r) => (parsePermissions(r.permissions) & (MANAGE_ROLES | ADMINISTRATOR)) !== 0n, ); if (!canManage) return { ok: false, error: "bot lacks Manage Roles" }; const botTop = botRoles.reduce((max, r) => Math.max(max, r.position), -1); diff --git a/src/jobs/contacts.ts b/src/jobs/contacts.ts index ecbaaf07..183b57db 100644 --- a/src/jobs/contacts.ts +++ b/src/jobs/contacts.ts @@ -134,8 +134,9 @@ export async function runContactsJob(deps: { // label_ids wholesale, so each distinct union is its own call. const groups = new Map(); for (const u of diff.update) { - const key = u.labelIds.join(","); - const g = groups.get(key) ?? { labelIds: u.labelIds, ids: [] }; + const sortedLabelIds = [...u.labelIds].sort((a, b) => a - b); + const key = sortedLabelIds.join(","); + const g = groups.get(key) ?? { labelIds: sortedLabelIds, ids: [] }; g.ids.push(u.contactId); groups.set(key, g); } diff --git a/src/jobs/discord-roles.ts b/src/jobs/discord-roles.ts index 5477b7a0..e18b2fb0 100644 --- a/src/jobs/discord-roles.ts +++ b/src/jobs/discord-roles.ts @@ -37,6 +37,7 @@ export async function runDiscordRolesJob( managed: cfg.discord.roleIds, guildRoles, botRoleIds: botMember.roles, + everyoneRoleId: cfg.discord.guildId, }) : ({ ok: false, error: "bot is not a member of the configured guild" } as const); if (!validation.ok) { @@ -58,13 +59,23 @@ export async function runDiscordRolesJob( if (links.length > 0) { return { status: "ok", counts: { skipped: 1 } as Record }; } - const member = await discord.getGuildMember(opts.discordUserId); - if (!member) { - return { status: "ok", counts: { notInGuild: 1 } as Record }; - } - const remove = stripManagedRoles(cfg.discord.roleIds, member.roles); - for (const roleId of remove) { - await discord.removeMemberRole(opts.discordUserId, roleId); + let member; + let remove: string[]; + try { + member = await discord.getGuildMember(opts.discordUserId); + if (!member) { + return { status: "ok", counts: { notInGuild: 1 } as Record }; + } + remove = stripManagedRoles(cfg.discord.roleIds, member.roles); + for (const roleId of remove) { + await discord.removeMemberRole(opts.discordUserId, roleId); + } + } catch (err) { + if (err instanceof DiscordApiError && !err.transient) { + const msg = `discord role strip failed for ${opts.discordUserId}: ${err.message}`; + return { status: "failed", errorSummary: msg }; + } + throw err; } if (remove.length > 0) { await logAudit(db, { diff --git a/src/jobs/purge.ts b/src/jobs/purge.ts index 383645af..e0df4379 100644 --- a/src/jobs/purge.ts +++ b/src/jobs/purge.ts @@ -11,10 +11,7 @@ export async function runPurgeJob(deps: { db: Db }): Promise { const { db } = deps; return runJob(db, "purge", async () => { const now = new Date(); - const sessions = await db - .delete(session) - .where(lt(session.expiresAt, now)) - .returning({ id: session.id }); + const sessions = await db.delete(session).where(lt(session.expiresAt, now)); const oauth = await db .delete(oauthTransaction) .where( @@ -22,23 +19,21 @@ export async function runPurgeJob(deps: { db: Db }): Promise { isNotNull(oauthTransaction.consumedAt), lt(oauthTransaction.expiresAt, now), ), - ) - .returning({ id: oauthTransaction.id }); + ); const outboxRows = await db .delete(outbox) .where( and( isNotNull(outbox.dispatchedAt), - lt(outbox.createdAt, new Date(Date.now() - OUTBOX_RETENTION_MS)), + lt(outbox.dispatchedAt, new Date(now.getTime() - OUTBOX_RETENTION_MS)), ), - ) - .returning({ id: outbox.id }); + ); return { status: "ok", counts: { - sessions: sessions.length, - oauthTransactions: oauth.length, - outbox: outboxRows.length, + sessions: sessions.rowCount ?? 0, + oauthTransactions: oauth.rowCount ?? 0, + outbox: outboxRows.rowCount ?? 0, }, }; }); diff --git a/src/jobs/token-health.ts b/src/jobs/token-health.ts index 5c967ddd..11a5ed56 100644 --- a/src/jobs/token-health.ts +++ b/src/jobs/token-health.ts @@ -6,7 +6,7 @@ import { EveSsoError, verifyEveAccessToken } from "@/lib/esi/sso"; import { reclaimTransferredCharacter } from "@/services/accounts"; import { logAudit } from "@/services/audit"; import { runJob, type JobResult } from "@/services/sync-run"; -import { getFreshAccessToken } from "@/services/tokens"; +import { getFreshAccessToken, invalidateTokenIfUnchanged } from "@/services/tokens"; export async function runTokenHealthJob(deps: { db: Db; @@ -40,22 +40,9 @@ export async function runTokenHealthJob(deps: { identity = await verifyEveAccessToken(token.accessToken); } catch (err) { if (err instanceof EveSsoError) { - const applied = await db.transaction(async (tx) => { - const rows = await tx - .update(character) - .set({ tokenStatus: "invalid" }) - .where( - and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), - ) - .returning({ id: character.id }); - if (rows.length === 0) return false; - await logAudit(tx, { - actor: "system", - action: "token.verify_failed", - target: String(ch.id), - details: { error: err.message }, - }); - return true; + const applied = await invalidateTokenIfUnchanged(db, ch.id, token.tokenEnc, { + action: "token.verify_failed", + details: { error: err.message }, }); if (applied) counts.invalid++; else transientFailures++; @@ -69,22 +56,9 @@ export async function runTokenHealthJob(deps: { // Fail closed: a token whose subject is another character must never // vouch for this row. Guard on the blob our CAS just stored so a // concurrent re-auth/reclaim discards this stale decision. - const applied = await db.transaction(async (tx) => { - const rows = await tx - .update(character) - .set({ tokenStatus: "invalid" }) - .where( - and(eq(character.id, ch.id), eq(character.refreshTokenEnc, token.tokenEnc)), - ) - .returning({ id: character.id }); - if (rows.length === 0) return false; - await logAudit(tx, { - actor: "system", - action: "token.subject_mismatch", - target: String(ch.id), - details: { subjectCharacterId: identity.characterId }, - }); - return true; + const applied = await invalidateTokenIfUnchanged(db, ch.id, token.tokenEnc, { + action: "token.subject_mismatch", + details: { subjectCharacterId: identity.characterId }, }); if (applied) counts.invalid++; else transientFailures++; diff --git a/src/lib/discord/rest.ts b/src/lib/discord/rest.ts index 161e96ec..f943e8f7 100644 --- a/src/lib/discord/rest.ts +++ b/src/lib/discord/rest.ts @@ -22,6 +22,17 @@ const roleSchema = z.object({ const memberSchema = z.object({ roles: z.array(z.string()) }); const userSchema = z.object({ id: z.string() }); +/** Malformed bodies are deterministic — fail closed as permanent, never retry-loop. */ +function safeParse(schema: z.ZodSchema, data: unknown, method: string, path: string): T { + try { + return schema.parse(data); + } catch { + throw new DiscordApiError(`discord ${method} ${path}: malformed response body`, { + transient: false, + }); + } +} + export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch) { async function rawRequest(path: string, init: RequestInit = {}): Promise { try { @@ -60,12 +71,14 @@ export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch return { async getGuildRoles() { - const res = await request(`/guilds/${guild}/roles`); - return z.array(roleSchema).parse(await res.json()); + const path = `/guilds/${guild}/roles`; + const res = await request(path); + return safeParse(z.array(roleSchema), await res.json(), "GET", path); }, async getBotUserId(): Promise { - const res = await request("/users/@me"); - return userSchema.parse(await res.json()).id; + const path = "/users/@me"; + const res = await request(path); + return safeParse(userSchema, await res.json(), "GET", path).id; }, /** null when the user is not in the guild (404). */ async getGuildMember(userId: string): Promise<{ roles: string[] } | null> { @@ -73,7 +86,7 @@ export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch const res = await rawRequest(path); if (res.status === 404) return null; assertOk(res, "GET", path); - return memberSchema.parse(await res.json()); + return safeParse(memberSchema, await res.json(), "GET", path); }, async addMemberRole(userId: string, roleId: string): Promise { await request(`/guilds/${guild}/members/${userId}/roles/${roleId}`, { diff --git a/src/lib/esi/client.ts b/src/lib/esi/client.ts index 84c91c39..409ef5a9 100644 --- a/src/lib/esi/client.ts +++ b/src/lib/esi/client.ts @@ -105,8 +105,14 @@ export function createEsiClient(opts: EsiClientOptions = {}) { }); const remainHeader = res.headers.get("x-esi-error-limit-remain"); const resetHeader = res.headers.get("x-esi-error-limit-reset"); - if (remainHeader !== null) remain = Number(remainHeader); - if (resetHeader !== null) resetAt = now() + Number(resetHeader) * 1000; + if (remainHeader !== null) { + const parsed = Number(remainHeader); + if (Number.isFinite(parsed)) remain = parsed; + } + if (resetHeader !== null) { + const parsed = Number(resetHeader); + if (Number.isFinite(parsed)) resetAt = now() + parsed * 1000; + } if (!res.ok) { const body = (await res.json().catch(() => undefined)) as | { error?: string } diff --git a/src/lib/wanderer/client.ts b/src/lib/wanderer/client.ts index ee13967e..e3b7ff33 100644 --- a/src/lib/wanderer/client.ts +++ b/src/lib/wanderer/client.ts @@ -20,7 +20,11 @@ export class WandererError extends Error { } } -const eveIdSchema = z.union([z.string().regex(/^\d+$/), z.number().int()]); +const eveIdSchema = z + .union([z.string().regex(/^\d+$/), z.number().int()]) + .refine((v) => Number.isSafeInteger(Number(v)) && Number(v) > 0, { + message: "EVE id must be a positive safe integer", + }); const roleSchema = z.enum(["admin", "manager", "member", "viewer", "blocked"]); // Strict on both axes, fail closed: an unknown role spelling could cost an // entry its admin protection, and a member with zero/multiple external ids diff --git a/src/services/tokens.ts b/src/services/tokens.ts index 5a0ce78d..438a2fc3 100644 --- a/src/services/tokens.ts +++ b/src/services/tokens.ts @@ -23,11 +23,11 @@ export type AccessTokenResult = * guard wins. A miss means the row changed underneath us (rotation, re-auth, * or transfer reclaim): the stale decision is discarded. */ -async function invalidateIfUnchanged( +export async function invalidateTokenIfUnchanged( db: Db, characterId: number, expectedEnc: string, - reason: string, + audit: { action: string; details?: Record }, ): Promise { return db.transaction(async (tx) => { const rows = await tx @@ -40,9 +40,9 @@ async function invalidateIfUnchanged( if (rows.length === 0) return false; await logAudit(tx, { actor: "system", - action: "token.invalidated", + action: audit.action, target: String(characterId), - details: { reason }, + details: audit.details, }); return true; }); @@ -66,7 +66,10 @@ export async function getFreshAccessToken( try { refreshToken = decryptToken(ch.refreshTokenEnc, cfg.tokenEncryptionKey); } catch { - const applied = await invalidateIfUnchanged(db, ch.id, ch.refreshTokenEnc, "malformed_token_blob"); + const applied = await invalidateTokenIfUnchanged(db, ch.id, ch.refreshTokenEnc, { + action: "token.invalidated", + details: { reason: "malformed_token_blob" }, + }); return applied ? { ok: false, reason: "invalid", detail: "malformed_token_blob" } : { ok: false, reason: "transient", detail: "concurrent rotation" }; @@ -97,12 +100,10 @@ export async function getFreshAccessToken( // invalid_grant on the OLD blob says nothing about a token another job // rotated in the meantime — the conditional update discards the stale // decision atomically (no separate read-then-write window). - const applied = await invalidateIfUnchanged( - db, - ch.id, - ch.refreshTokenEnc, - err.oauthError ?? `status_${err.status}`, - ); + const applied = await invalidateTokenIfUnchanged(db, ch.id, ch.refreshTokenEnc, { + action: "token.invalidated", + details: { reason: err.oauthError ?? `status_${err.status}` }, + }); return applied ? { ok: false, reason: "invalid", detail: err.oauthError } : { ok: false, reason: "transient", detail: "concurrent rotation" }; From 009ac6539cba389a9317cf5a0e78b39838e69c38 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:48:26 -0400 Subject: [PATCH 26/28] fix: harden worker job/dispatcher lifecycle - Handler registration iterates the whole delivered jobs array instead of destructuring only the first job. - Dead-letter handler wraps parse + webhook post in try/catch so a malformed dead-letter payload or a failed webhook post can't crash the worker or silently vanish. - Shutdown gets a re-entry guard (a second SIGTERM/SIGINT is a no-op), wraps cleanup in try/catch, and exits 1 on cleanup failure instead of hanging or exiting 0 on a broken shutdown. - startDispatcher's stop function is now async: it clears the interval and awaits any in-flight dispatch run, so shutdown can't race a dispatch that's mid-transaction. Documented the resulting at-least-once contract on dispatchOutbox. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- src/worker/dispatcher.ts | 22 +++++++++++++++++++--- src/worker/index.ts | 36 ++++++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/worker/dispatcher.ts b/src/worker/dispatcher.ts index c406559e..9fdd0cf0 100644 --- a/src/worker/dispatcher.ts +++ b/src/worker/dispatcher.ts @@ -81,6 +81,14 @@ export function planDispatch( * takeUndispatched/markDispatched contract): a failed send rolls the claim * back so rows are re-attempted next tick. FOR UPDATE SKIP LOCKED makes * concurrent dispatchers safe without advisory locks. + * + * At-least-once contract: sends happen inside the transaction that also + * marks rows dispatched, but the two are not atomic with each other beyond + * the transaction boundary — if the transaction fails to commit AFTER the + * sends have gone out (e.g. a commit-time failure), those jobs are already + * enqueued while their outbox rows remain undispatched. The next tick will + * re-claim and re-send the same rows; pg-boss's singleton keys coalesce the + * resulting duplicates, so this is safe but not exactly-once. */ export async function dispatchOutbox(db: Db, send: QueueSend): Promise { return db.transaction(async (tx) => { @@ -99,16 +107,24 @@ export async function dispatchOutbox(db: Db, send: QueueSend): Promise { }); } -export function startDispatcher(db: Db, send: QueueSend, intervalMs = 2000): () => void { +export function startDispatcher( + db: Db, + send: QueueSend, + intervalMs = 2000, +): () => Promise { let running = false; + let inFlight: Promise = Promise.resolve(); const timer = setInterval(() => { if (running) return; running = true; - void dispatchOutbox(db, send) + inFlight = dispatchOutbox(db, send) .catch((err) => console.error("outbox dispatch failed", err)) .finally(() => { running = false; }); }, intervalMs); - return () => clearInterval(timer); + return async () => { + clearInterval(timer); + await inFlight; + }; } diff --git a/src/worker/index.ts b/src/worker/index.ts index 2767aab7..1c38ac17 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -30,17 +30,25 @@ async function main(): Promise { }); // pg-boss v10 handlers receive an ARRAY of jobs. for (const [queue, handler] of Object.entries(handlers)) { - await boss.work(queue, async ([job]) => handler(job.data)); + await boss.work(queue, async (jobs) => { + for (const job of jobs) await handler(job.data); + }); } // Ops alerting (spec: Error handling): a job landing here exhausted its - // retries — post to the optional Discord ops webhook. + // retries — post to the optional Discord ops webhook. Guarded so a + // malformed dead-letter payload or a failed webhook post can neither crash + // the worker nor silently vanish. await boss.work(QUEUES.deadLetter, async ([job]) => { - const data = deadLetterSchema.parse(job.data); - await postOpsWebhook( - cfg, - `authGD: job \`${data?.jobType ?? "unknown"}\` failed after final retry.`, - ); + try { + const data = deadLetterSchema.parse(job.data); + await postOpsWebhook( + cfg, + `authGD: job \`${data?.jobType ?? "unknown"}\` failed after final retry.`, + ); + } catch (err) { + console.error("dead-letter handler failed", err); + } }); await scheduleJobs(boss); @@ -48,10 +56,18 @@ async function main(): Promise { boss.send(queue, data, options), ); + let shuttingDown = false; const shutdown = async (): Promise => { - stopDispatcher(); - await boss.stop({ graceful: true, wait: true }); - await pool.end(); + if (shuttingDown) return; // re-entrant SIGTERM/SIGINT is a no-op + shuttingDown = true; + try { + await stopDispatcher(); + await boss.stop({ graceful: true, wait: true }); + await pool.end(); + } catch (err) { + console.error("worker shutdown failed", err); + process.exit(1); + } process.exit(0); }; process.on("SIGTERM", () => void shutdown()); From 54f2043e100c5272463ebd6d20b5ce884bd2b86e Mon Sep 17 00:00:00 2001 From: Guarzo Date: Sun, 2 Aug 2026 23:48:32 -0400 Subject: [PATCH 27/28] test: cover hardening fixes and dedupe shared test scaffolding - Add coverage for the new src-side hardening: chunk() size validation, @everyone-role Manage Roles grant + malformed permissions, discord strip-path DiscordApiError classification, malformed Discord REST bodies, and oversized wanderer EVE ids. - purge-job.test.ts: anchor the retention check on dispatchedAt (not just createdAt) and assert the survivors' dispatchedAt values directly. - contacts-job.test.ts: sort both sides of the multi-id add assertions since getFlygdCharacters carries no ORDER BY guarantee. - deprovision-flow.test.ts: assert dispatched is exactly 1 and that the stayer's role is never removed. - helpers/db.ts: export truncateAll(db) and TEST_URL; replace the duplicated inline 11-table TRUNCATE and TEST_DATABASE_URL fallback across every test file that carried them. Claude-Session: https://claude.ai/code/session_016odmULfR3ptiZhsDEnsU7L --- tests/accounts.test.ts | 12 +++--------- tests/auth-routes.test.ts | 6 ++---- tests/contacts-job.test.ts | 20 ++++++++------------ tests/deprovision-flow.test.ts | 14 ++++---------- tests/desired.test.ts | 11 ++--------- tests/discord-link.test.ts | 6 ++---- tests/discord-rest.test.ts | 11 +++++++++++ tests/discord-roles-job.test.ts | 29 ++++++++++++++++++++--------- tests/dispatcher.test.ts | 12 +++--------- tests/esi-client.test.ts | 6 ++++++ tests/helpers/db.ts | 15 ++++++++++----- tests/membership-job.test.ts | 12 +++--------- tests/purge-job.test.ts | 19 ++++++++----------- tests/role-diff.test.ts | 23 +++++++++++++++++++++++ tests/token-health-job.test.ts | 12 +++--------- tests/tokens.test.ts | 12 +++--------- tests/wanderer-client.test.ts | 9 +++++++++ tests/wanderer-job.test.ts | 11 ++--------- tests/worker-queues.test.ts | 5 +---- 19 files changed, 123 insertions(+), 122 deletions(-) diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts index 95d9d242..d493ac7b 100644 --- a/tests/accounts.test.ts +++ b/tests/accounts.test.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { loadConfig, type Config } from "@/config"; import { account, auditLog, bootstrapAdminGrant, character, outbox, session } from "@/db/schema"; @@ -13,7 +13,7 @@ import { } from "@/services/accounts"; import { createSession, getSessionAccount } from "@/services/session"; import { decryptToken } from "@/lib/crypto"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; let ctx: Awaited>; let cfg: Config; @@ -52,13 +52,7 @@ beforeAll(async () => { WANDERER_ACL_ID: "a", } as NodeJS.ProcessEnv); }); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); afterAll(() => ctx.cleanup()); // Identity mutations require a transaction (DbTx); these helpers wrap each call. diff --git a/tests/auth-routes.test.ts b/tests/auth-routes.test.ts index 63858fc0..37cfce52 100644 --- a/tests/auth-routes.test.ts +++ b/tests/auth-routes.test.ts @@ -5,12 +5,10 @@ import { setupServer } from "msw/node"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { account, character } from "@/db/schema"; import { setTestJwksOverride } from "@/lib/esi/sso"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, TEST_URL } from "./helpers/db"; // Route modules read config + db lazily via getConfig()/getDb(); set env first. -process.env.DATABASE_URL = - process.env.TEST_DATABASE_URL ?? - "postgres://authgd:authgd@localhost:5433/authgd_test"; +process.env.DATABASE_URL = TEST_URL; process.env.TOKEN_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); process.env.APP_BASE_URL = "http://localhost:3000"; process.env.ALLIANCE_ID = "99000001"; diff --git a/tests/contacts-job.test.ts b/tests/contacts-job.test.ts index 30a3cf6e..4006f948 100644 --- a/tests/contacts-job.test.ts +++ b/tests/contacts-job.test.ts @@ -1,10 +1,10 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { character, contactSyncState } from "@/db/schema"; import { canPushContacts, runContactsJob, type ContactsEsi } from "@/jobs/contacts"; import { EsiError, type EsiContact } from "@/lib/esi/client"; import { JobRetryError } from "@/services/sync-run"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -16,13 +16,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); const okToken = (async () => new Response( @@ -122,9 +116,11 @@ describe("runContactsJob", () => { expect(calls.edits).toContainEqual({ characterId: 1, ids: [2], labelIds: [5, LABEL_ID] }); expect(calls.deletes).toContainEqual({ characterId: 1, ids: [99] }); expect(calls.adds.filter((c) => c.characterId === 1)).toEqual([]); - // characters 2 and 3 each get the other two added - expect(calls.adds).toContainEqual({ characterId: 2, ids: [1, 3], labelIds: [LABEL_ID] }); - expect(calls.adds).toContainEqual({ characterId: 3, ids: [1, 2], labelIds: [LABEL_ID] }); + // characters 2 and 3 each get the other two added — order-independent, + // since getFlygdCharacters carries no ORDER BY guarantee. + const sortedAdds = calls.adds.map((c) => ({ ...c, ids: [...c.ids].sort((a, b) => a - b) })); + expect(sortedAdds).toContainEqual({ characterId: 2, ids: [1, 3], labelIds: [LABEL_ID] }); + expect(sortedAdds).toContainEqual({ characterId: 3, ids: [1, 2], labelIds: [LABEL_ID] }); expect((await lastResult(1))?.lastResult).toBe("ok"); expect((await lastResult(1))?.lastSyncedAt).not.toBeNull(); }); diff --git a/tests/deprovision-flow.test.ts b/tests/deprovision-flow.test.ts index d655026c..9b4d4614 100644 --- a/tests/deprovision-flow.test.ts +++ b/tests/deprovision-flow.test.ts @@ -1,4 +1,3 @@ -import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; import { auditLog, wandererAclObservation } from "@/db/schema"; import type { DiscordClient } from "@/lib/discord/rest"; @@ -6,7 +5,7 @@ import type { Affiliation } from "@/lib/esi/client"; import type { WandererAclMember, WandererClient } from "@/lib/wanderer/client"; import { dispatchOutbox } from "@/worker/dispatcher"; import { buildJobHandlers, type JobDeps } from "@/worker/handlers"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -18,13 +17,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); const okToken = (async () => new Response( @@ -132,7 +125,7 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang const dispatched = await dispatchOutbox(ctx.db, async (queue, data) => { sent.push({ queue, data }); }); - expect(dispatched).toBeGreaterThanOrEqual(1); + expect(dispatched).toBe(1); // exactly one demoted account expect(new Set(sent.map((s) => s.queue))).toEqual( new Set(["membership", "contacts", "wanderer", "discord-roles"]), ); @@ -157,6 +150,7 @@ it("main leaves alliance → green → contacts removed, ACL removed, role chang expect(roleOps.added).toContainEqual(["u-leaver", "12"]); expect(roleOps.removed).toContainEqual(["u-leaver", "10"]); expect(roleOps.added).not.toContainEqual(["u-stayer", "12"]); + expect(roleOps.removed).not.toContainEqual(["u-stayer", "10"]); // 7) Audit trail: demotion cause + downstream actions all recorded. const audits = await ctx.db.select().from(auditLog); diff --git a/tests/desired.test.ts b/tests/desired.test.ts index aebe62dc..dcf1cdb2 100644 --- a/tests/desired.test.ts +++ b/tests/desired.test.ts @@ -1,7 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { sql } from "drizzle-orm"; import { getFlygdCharacters } from "@/services/desired"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -12,13 +11,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); describe("getFlygdCharacters", () => { it("returns every character of every flygd account and nothing else", async () => { diff --git a/tests/discord-link.test.ts b/tests/discord-link.test.ts index 21082c14..b34c05c2 100644 --- a/tests/discord-link.test.ts +++ b/tests/discord-link.test.ts @@ -2,12 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { sql } from "drizzle-orm"; import { account, discordLink, outbox } from "@/db/schema"; import { linkDiscord } from "@/services/discord-link"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, TEST_URL } from "./helpers/db"; // Route modules read config + db lazily via getConfig()/getDb(); set env first. -process.env.DATABASE_URL = - process.env.TEST_DATABASE_URL ?? - "postgres://authgd:authgd@localhost:5433/authgd_test"; +process.env.DATABASE_URL = TEST_URL; process.env.TOKEN_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); process.env.APP_BASE_URL = "http://localhost:3000"; process.env.ALLIANCE_ID = "99000001"; diff --git a/tests/discord-rest.test.ts b/tests/discord-rest.test.ts index 86e41e39..2997d0a7 100644 --- a/tests/discord-rest.test.ts +++ b/tests/discord-rest.test.ts @@ -27,6 +27,17 @@ describe("createDiscordClient", () => { ]); }); + it("throws a permanent DiscordApiError on a malformed roles body", async () => { + server.use( + http.get(`${API}/guilds/9000/roles`, () => + HttpResponse.json([{ id: "10", position: "not-a-number" }]), + ), + ); + const err = await createDiscordClient(cfg).getGuildRoles().catch((e: unknown) => e); + expect(err).toBeInstanceOf(DiscordApiError); + expect((err as DiscordApiError).transient).toBe(false); + }); + it("returns null for a 404 guild member (user not in guild)", async () => { server.use( http.get(`${API}/guilds/9000/members/u1`, () => diff --git a/tests/discord-roles-job.test.ts b/tests/discord-roles-job.test.ts index dc044e95..3fa8772d 100644 --- a/tests/discord-roles-job.test.ts +++ b/tests/discord-roles-job.test.ts @@ -1,9 +1,8 @@ -import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { auditLog, outbox, syncRun } from "@/db/schema"; import { runDiscordRolesJob } from "@/jobs/discord-roles"; import { DiscordApiError, type DiscordClient } from "@/lib/discord/rest"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -14,13 +13,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); const MANAGE_ROLES = String(1 << 28); const validGuildRoles = [ @@ -144,6 +137,24 @@ describe("runDiscordRolesJob", () => { expect(d.added).toEqual([]); }); + it("a permanent DiscordApiError during a strip resolves with status failed (no throw)", async () => { + const d = fakeDiscord({ u9: ["10", "12"] }); + const client: DiscordClient = { + ...d.client, + removeMemberRole: async () => { + throw new DiscordApiError("discord DELETE roles failed (403)", { + status: 403, + transient: false, + }); + }, + }; + const result = await runDiscordRolesJob( + { db: ctx.db, cfg, discord: client }, + { discordUserId: "u9" }, + ); + expect(result.status).toBe("failed"); + }); + it("re-syncs the account when a re-link lands DURING the strip", async () => { const d = fakeDiscord({ u9: ["10"] }); // the re-link commits while the strip's role removal is in flight diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index bf2f16c0..1a73bae3 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -1,22 +1,16 @@ -import { isNull, sql } from "drizzle-orm"; +import { isNull } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { outbox } from "@/db/schema"; import { enqueueSync } from "@/services/outbox"; import { dispatchOutbox, planDispatch } from "@/worker/dispatcher"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; let ctx: Awaited>; beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); type Sent = { queue: string; data: Record; singletonKey: string }; const collector = () => { diff --git a/tests/esi-client.test.ts b/tests/esi-client.test.ts index d0ab04eb..4d83b725 100644 --- a/tests/esi-client.test.ts +++ b/tests/esi-client.test.ts @@ -16,6 +16,12 @@ describe("chunk", () => { expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); expect(chunk([], 3)).toEqual([]); }); + + it("throws when size is not a positive integer", () => { + expect(() => chunk([1, 2], 0)).toThrow(/positive integer/); + expect(() => chunk([1, 2], -1)).toThrow(/positive integer/); + expect(() => chunk([1, 2], 1.5)).toThrow(/positive integer/); + }); }); describe("postAffiliation", () => { diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 21a9b734..e8179bab 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,18 +1,23 @@ import { sql } from "drizzle-orm"; import { migrate } from "drizzle-orm/node-postgres/migrator"; -import { createDb } from "@/db"; +import { createDb, type Db } from "@/db"; -const TEST_URL = +export const TEST_URL = process.env.TEST_DATABASE_URL ?? "postgres://authgd:authgd@localhost:5433/authgd_test"; -export async function setupTestDb() { - const { db, pool } = createDb(TEST_URL); - await migrate(db, { migrationsFolder: "drizzle" }); +/** Shared 11-table TRUNCATE used between tests to reset carry-over state. */ +export async function truncateAll(db: Db): Promise { await db.execute(sql` TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, outbox, oauth_transaction, contact_sync_state, sync_run, wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE `); +} + +export async function setupTestDb() { + const { db, pool } = createDb(TEST_URL); + await migrate(db, { migrationsFolder: "drizzle" }); + await truncateAll(db); return { db, pool, cleanup: () => pool.end() }; } diff --git a/tests/membership-job.test.ts b/tests/membership-job.test.ts index b9af0cc1..f24cd161 100644 --- a/tests/membership-job.test.ts +++ b/tests/membership-job.test.ts @@ -1,10 +1,10 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { account, auditLog, character, outbox } from "@/db/schema"; import { runMembershipJob } from "@/jobs/membership"; import { EsiError, type Affiliation } from "@/lib/esi/client"; import { JobRetryError } from "@/services/sync-run"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -15,13 +15,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); /** ESI fake: every id resolves into the given alliance (or none). */ const esiWith = (alliances: Record) => ({ diff --git a/tests/purge-job.test.ts b/tests/purge-job.test.ts index c4910843..59811a3f 100644 --- a/tests/purge-job.test.ts +++ b/tests/purge-job.test.ts @@ -1,8 +1,7 @@ -import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { oauthTransaction, outbox, session } from "@/db/schema"; import { runPurgeJob } from "@/jobs/purge"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { seedAccount } from "./helpers/seed"; let ctx: Awaited>; @@ -10,13 +9,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); const DAY = 24 * 60 * 60 * 1000; @@ -34,8 +27,8 @@ describe("runPurgeJob", () => { ]); await ctx.db.insert(outbox).values([ { payload: { kind: "all" } }, // undispatched → NEVER purged + { payload: { kind: "all" }, dispatchedAt: new Date(Date.now() - 8 * DAY), createdAt: new Date(Date.now() - 8 * DAY) }, { payload: { kind: "all" }, dispatchedAt: new Date(), createdAt: new Date(Date.now() - 8 * DAY) }, - { payload: { kind: "all" }, dispatchedAt: new Date(), createdAt: new Date(Date.now() - DAY) }, ]); const result = await runPurgeJob({ db: ctx.db }); @@ -44,6 +37,10 @@ describe("runPurgeJob", () => { expect((await ctx.db.select().from(session)).map((s) => s.id)).toEqual(["live"]); expect((await ctx.db.select().from(oauthTransaction)).map((t) => t.stateHash)).toEqual(["live"]); - expect(await ctx.db.select().from(outbox)).toHaveLength(2); + const survivors = await ctx.db.select().from(outbox); + expect(survivors).toHaveLength(2); + const dispatchedAts = survivors.map((r) => r.dispatchedAt); + expect(dispatchedAts).toContainEqual(null); // undispatched survivor + expect(dispatchedAts.some((d) => d !== null && d.getTime() > Date.now() - DAY)).toBe(true); // recent-dispatched survivor }); }); diff --git a/tests/role-diff.test.ts b/tests/role-diff.test.ts index fbaa67bb..d2fd0503 100644 --- a/tests/role-diff.test.ts +++ b/tests/role-diff.test.ts @@ -84,4 +84,27 @@ describe("validateRoleConfig", () => { }); expect(r).toMatchObject({ ok: false }); }); + it("accepts Manage Roles granted only via the @everyone role", () => { + const guildId = "everyone-1"; + const r = validateRoleConfig({ + managed, + guildRoles: [ + ...guildRoles.map((g) => (g.id === "bot-role" ? { ...g, permissions: "0" } : g)), + { id: guildId, position: 0, permissions: MANAGE_ROLES }, + ], + botRoleIds: ["bot-role"], + everyoneRoleId: guildId, + }); + expect(r).toEqual({ ok: true }); + }); + it("a malformed permissions string doesn't throw and fails closed", () => { + const r = validateRoleConfig({ + managed, + guildRoles: guildRoles.map((g) => + g.id === "bot-role" ? { ...g, permissions: "not-a-number" } : g, + ), + botRoleIds: ["bot-role"], + }); + expect(r).toMatchObject({ ok: false, error: expect.stringContaining("Manage Roles") }); + }); }); diff --git a/tests/token-health-job.test.ts b/tests/token-health-job.test.ts index 394234ea..23bb0fbe 100644 --- a/tests/token-health-job.test.ts +++ b/tests/token-health-job.test.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { SignJWT, createLocalJWKSet, @@ -19,7 +19,7 @@ import { setTestJwksOverride } from "@/lib/esi/sso"; import { reclaimTransferredCharacter } from "@/services/accounts"; import { JobRetryError } from "@/services/sync-run"; import { createSession } from "@/services/session"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -37,13 +37,7 @@ beforeAll(async () => { }); afterAll(() => ctx.cleanup()); afterAll(() => setTestJwksOverride(undefined)); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); async function signAccessToken(opts: { characterId: number; diff --git a/tests/tokens.test.ts b/tests/tokens.test.ts index 1c83d13c..87e8e6a0 100644 --- a/tests/tokens.test.ts +++ b/tests/tokens.test.ts @@ -1,9 +1,9 @@ -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { auditLog, character } from "@/db/schema"; import { decryptToken, encryptToken } from "@/lib/crypto"; import { getFreshAccessToken } from "@/services/tokens"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -14,13 +14,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); const tokenJson = (body: unknown, status = 200) => new Response(JSON.stringify(body), { diff --git a/tests/wanderer-client.test.ts b/tests/wanderer-client.test.ts index 8c3aa0d8..f6a74c20 100644 --- a/tests/wanderer-client.test.ts +++ b/tests/wanderer-client.test.ts @@ -58,6 +58,15 @@ describe("createWandererClient", () => { await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); }); + it("fails closed on an id that overflows safe integer range", async () => { + server.use( + http.get(ACL, () => + aclResponse([{ eve_character_id: "12345678901234567890", role: "viewer" }]), + ), + ); + await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + }); + it("fails closed on zero or multiple external ids", async () => { server.use(http.get(ACL, () => aclResponse([{ role: "viewer" }]))); await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); diff --git a/tests/wanderer-job.test.ts b/tests/wanderer-job.test.ts index 140844bb..fe83cdc8 100644 --- a/tests/wanderer-job.test.ts +++ b/tests/wanderer-job.test.ts @@ -1,4 +1,3 @@ -import { sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { auditLog, wandererAclObservation } from "@/db/schema"; import { runWandererJob } from "@/jobs/wanderer"; @@ -8,7 +7,7 @@ import { type WandererClient, } from "@/lib/wanderer/client"; import { JobRetryError } from "@/services/sync-run"; -import { setupTestDb } from "./helpers/db"; +import { setupTestDb, truncateAll } from "./helpers/db"; import { testConfig } from "./helpers/config"; import { seedAccount, seedCharacter } from "./helpers/seed"; @@ -19,13 +18,7 @@ beforeAll(async () => { ctx = await setupTestDb(); }); afterAll(() => ctx.cleanup()); -beforeEach(async () => { - await ctx.db.execute(sql` - TRUNCATE account, "character", discord_link, session, bootstrap_admin_grant, - outbox, oauth_transaction, contact_sync_state, sync_run, - wanderer_acl_observation, audit_log RESTART IDENTITY CASCADE - `); -}); +beforeEach(() => truncateAll(ctx.db)); type Member = WandererAclMember; diff --git a/tests/worker-queues.test.ts b/tests/worker-queues.test.ts index 1dc7110b..4dc35478 100644 --- a/tests/worker-queues.test.ts +++ b/tests/worker-queues.test.ts @@ -1,10 +1,7 @@ import PgBoss from "pg-boss"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { QUEUES, createQueues, scheduleJobs } from "@/worker/queues"; - -const TEST_URL = - process.env.TEST_DATABASE_URL ?? - "postgres://authgd:authgd@localhost:5433/authgd_test"; +import { TEST_URL } from "./helpers/db"; let boss: PgBoss; beforeAll(async () => { From e5cc3ac17433d75a9fc43a93e60b233ef9a169ab Mon Sep 17 00:00:00 2001 From: Guarzo Date: Mon, 3 Aug 2026 00:01:07 -0400 Subject: [PATCH 28/28] fix: digits-only permission parsing, JSON-inclusive Discord body classification, sharper wanderer test --- src/core/role-diff.ts | 12 +++++------- src/lib/discord/rest.ts | 19 +++++++++++++------ tests/discord-rest.test.ts | 14 ++++++++++++++ tests/wanderer-client.test.ts | 5 ++++- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/core/role-diff.ts b/src/core/role-diff.ts index d0150ad6..e46a6d8f 100644 --- a/src/core/role-diff.ts +++ b/src/core/role-diff.ts @@ -32,14 +32,12 @@ const ADMINISTRATOR = 1n << 3n; * bot has Manage Roles (or Administrator); bot's highest role sits ABOVE * every managed role. Failure is permanent-config — no retry loop. */ -/** Malformed permissions strings must never grant access — treat as zero. */ +/** Malformed permissions strings must never grant access — treat as zero. + * Digits-only: BigInt would also accept hex ("0x...") and padded input, which + * Discord never sends and which must not sneak permissions in. */ function parsePermissions(permissions: string): bigint { - try { - const n = BigInt(permissions); - return n < 0n ? 0n : n; - } catch { - return 0n; - } + if (!/^\d+$/.test(permissions)) return 0n; + return BigInt(permissions); } export function validateRoleConfig(input: { diff --git a/src/lib/discord/rest.ts b/src/lib/discord/rest.ts index f943e8f7..ee0c8525 100644 --- a/src/lib/discord/rest.ts +++ b/src/lib/discord/rest.ts @@ -22,10 +22,17 @@ const roleSchema = z.object({ const memberSchema = z.object({ roles: z.array(z.string()) }); const userSchema = z.object({ id: z.string() }); -/** Malformed bodies are deterministic — fail closed as permanent, never retry-loop. */ -function safeParse(schema: z.ZodSchema, data: unknown, method: string, path: string): T { +/** Malformed bodies are deterministic — fail closed as permanent, never + * retry-loop. Reads the body here so invalid JSON classifies the same way as + * a schema failure. */ +async function parseBody( + schema: z.ZodSchema, + res: Response, + method: string, + path: string, +): Promise { try { - return schema.parse(data); + return schema.parse(await res.json()); } catch { throw new DiscordApiError(`discord ${method} ${path}: malformed response body`, { transient: false, @@ -73,12 +80,12 @@ export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch async getGuildRoles() { const path = `/guilds/${guild}/roles`; const res = await request(path); - return safeParse(z.array(roleSchema), await res.json(), "GET", path); + return parseBody(z.array(roleSchema), res, "GET", path); }, async getBotUserId(): Promise { const path = "/users/@me"; const res = await request(path); - return safeParse(userSchema, await res.json(), "GET", path).id; + return (await parseBody(userSchema, res, "GET", path)).id; }, /** null when the user is not in the guild (404). */ async getGuildMember(userId: string): Promise<{ roles: string[] } | null> { @@ -86,7 +93,7 @@ export function createDiscordClient(cfg: Config, fetchImpl: typeof fetch = fetch const res = await rawRequest(path); if (res.status === 404) return null; assertOk(res, "GET", path); - return safeParse(memberSchema, await res.json(), "GET", path); + return parseBody(memberSchema, res, "GET", path); }, async addMemberRole(userId: string, roleId: string): Promise { await request(`/guilds/${guild}/members/${userId}/roles/${roleId}`, { diff --git a/tests/discord-rest.test.ts b/tests/discord-rest.test.ts index 2997d0a7..06e3870e 100644 --- a/tests/discord-rest.test.ts +++ b/tests/discord-rest.test.ts @@ -38,6 +38,20 @@ describe("createDiscordClient", () => { expect((err as DiscordApiError).transient).toBe(false); }); + it("classifies a non-JSON body as a permanent DiscordApiError too", async () => { + server.use( + http.get(`${API}/guilds/9000/roles`, () => + new HttpResponse("gateway", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ); + const err = await createDiscordClient(cfg).getGuildRoles().catch((e: unknown) => e); + expect(err).toBeInstanceOf(DiscordApiError); + expect((err as DiscordApiError).transient).toBe(false); + }); + it("returns null for a 404 guild member (user not in guild)", async () => { server.use( http.get(`${API}/guilds/9000/members/u1`, () => diff --git a/tests/wanderer-client.test.ts b/tests/wanderer-client.test.ts index f6a74c20..ea709cc9 100644 --- a/tests/wanderer-client.test.ts +++ b/tests/wanderer-client.test.ts @@ -64,7 +64,10 @@ describe("createWandererClient", () => { aclResponse([{ eve_character_id: "12345678901234567890", role: "viewer" }]), ), ); - await expect(createWandererClient(cfg).getAclMembers()).rejects.toThrow(); + // assert the safe-integer validation itself fired, on the character-id path + const err = await createWandererClient(cfg).getAclMembers().catch((e: unknown) => e); + expect(String(err)).toMatch(/positive safe integer/); + expect(String(err)).toMatch(/eve_character_id/); }); it("fails closed on zero or multiple external ids", async () => {