This is the migration and parity-test contract for KLUB-244, KLUB-246, KLUB-247, and KLUB-254. The goal is to keep migrated API routes as thin Astro adapters while preserving the current user-visible behavior unless this document explicitly defines the schema-v2 replacement behavior.
Decisions locked by this contract:
requireAuth(locals.user) guard that returns 401 JSON. The legacy redirecting requireAuth(request, context) implementation is not used by API routes.GET /api/fantasy-leagues/format-availability is public and returns the wire format defined below.200/202/409 behavior. The domain service must support no-create joins; it must not create a league when the HTTP adapter requests the pool flow.Current sprint endpoint audit:
| Endpoint | Current pattern | Migration target |
|---|---|---|
POST /api/roster/update |
Inline locals.user auth, inline window/team/lineup validation, writes active_rosters and active_rosters_history. |
KLUB-243 reference adapter after migration. |
POST /api/draft/:leagueId/pick |
Inline participant, turn, pick-rule, transaction, Supabase broadcast logic. | Adapter validates auth/body, service performs pick and returns broadcast payload data. |
POST /api/draft/:leagueId/auto-pick |
Uses verifyDraftAuth(context, "participant"), then inline auto-pick transaction and broadcast. |
Adapter validates participant auth, service performs auto-pick and returns broadcast payload data. |
POST /api/draft/:leagueId/complete |
Uses verifyDraftAuth(context, "owner"), updates league draft completion directly. |
Adapter validates owner auth, service completes draft lifecycle. |
POST /api/fantasy-leagues/create |
Inline auth, body validation, season/window creation, game limits, draft settings, Trigger scheduling. | Adapter keeps wire shape, service creates league and owned rows, adapter handles non-transactional scheduling. |
POST /api/fantasy-leagues/public/join |
Zod body validation, inline auth, calls legacy public-league service, optionally triggers pool refill. | Adapter calls the domain public-league service with allowCreate: false and keeps 200/202/409 response shapes. |
GET /api/fantasy-leagues/format-availability |
No source route exists today. Domain support exists in LeagueService.checkFormatAvailability. |
Public read adapter maps domain format names to the defined MID/FULL wire response. |
Required adapter-to-service calls:
| Endpoint | Service call |
|---|---|
POST /api/roster/update |
RosterService.setLineup(fantasyTeamId, windowId, teamIds) after the adapter resolves the caller's team and validates the window state. |
POST /api/draft/:leagueId/pick |
DraftService.makePick({ leagueId, userId, teamId }). |
POST /api/draft/:leagueId/auto-pick |
DraftService.autoPick(leagueId). |
POST /api/draft/:leagueId/complete |
DraftService.complete({ leagueId, userId }); this operation updates the league's active/completed state atomically. |
POST /api/fantasy-leagues/create |
LeagueService.createWithSetup(input); this operation atomically creates the league, owner team, owner participant, game limits, windows, and draft settings. |
POST /api/fantasy-leagues/public/join |
PublicLeagueService.join({ userId, format, season, allowCreate: false }). |
GET /api/fantasy-leagues/format-availability |
LeagueService.checkFormatAvailability(currentSeason). |
Every migrated endpoint follows this structure:
locals.user from the Astro API context. Do not call Supabase directly from the route.requireAuth(locals.user). It returns the authenticated user or Response.json({ error: "Unauthorized" }, { status: 401 }); API routes never use redirecting page auth.400 with the existing error shape for that endpoint.leagueFormat: "MID" | "FULL" at the API boundary.Response.json(payload, { status }) consistently.Target adapter shape:
export const POST: APIRoute = async (context) => {
const { request, locals, params } = context;
const auth = requireAuth(locals.user);
if (auth instanceof Response) return auth;
const user = auth;
const leagueId = params.leagueId;
if (!leagueId) {
return Response.json({ error: "League ID is required" }, { status: 400 });
}
try {
const payload = parseRequest(await request.json());
const result = await service.run({ ...payload, leagueId, userId: user.id });
return Response.json(toResponseBody(result), { status: 200 });
} catch (error) {
return mapServiceError(error);
}
};
All migrated endpoints use one of these access levels:
| Endpoint | Required auth rule |
|---|---|
/api/roster/update |
Authenticated user must own a fantasy_teams row for the target league/window. Missing team remains 404 to match current behavior. |
/api/draft/:leagueId/pick |
Authenticated user must be a league participant and it must be their turn. Non-participant remains 403. Wrong turn remains 403. |
/api/draft/:leagueId/auto-pick |
Authenticated user must be a league participant. The adapter replaces verifyDraftAuth(context, "participant") with the standard API auth guard plus participant check, preserving its 403 response. |
/api/draft/:leagueId/complete |
Authenticated user must be the league owner. The adapter replaces verifyDraftAuth(context, "owner") with the standard API auth guard plus owner check, preserving 403 for non-owners and 404 for a missing league. |
/api/fantasy-leagues/create |
Authenticated user only. Owner membership is created by the service. |
/api/fantasy-leagues/public/join |
Authenticated user only. Public membership rules are enforced by the public-league service. |
/api/fantasy-leagues/format-availability |
Public GET; no auth, no query parameters. The server resolves the current season and returns only format availability. |
KLUB-243 refactors the unused requireAuth export in src/middleware/auth.ts to requireAuth(user: User | null): User | Response. It returns the user when present and Response.json({ error: "Unauthorized" }, { status: 401 }) otherwise. Redirect behavior is removed from this helper; page redirects remain middleware behavior.
Every protected migrated endpoint has this authorization parity: unauthenticated 401 { error: "Unauthorized" }; authenticated but ineligible callers retain the route-specific 403 or 404 behavior listed below.
The domain service owns business rules and DB side effects. The Astro route owns HTTP concerns only.
Service responsibilities:
userId, leagueId, teamId, teamIds, format, and season.db.transaction() for all business writes.DraftServiceError, RosterServiceError, LeagueServiceError, or PublicLeagueServiceError.@/db/schema writes with schema-v2 service writes.Adapter responsibilities:
MID -> CAMPAIGN_8, FULL -> REST_OF_SEASON when calling domain services.500 response shape.Schema-v2 side-effect equivalence:
| Legacy observable write | Schema-v2 parity assertion |
|---|---|
active_rosters active rows |
Exactly four window_lineups rows for the fantasy team/window, with the selected clubs and positions. |
active_rosters_history version rows |
No replacement history rows. Schema-v2 intentionally eliminates pre-lock lineup history; parity asserts the final mutable window_lineups state only. |
fantasy_team_players created by a draft pick |
One fantasy_roster_entries row for the draft pick's fantasy team and club. |
league_game_limits |
Equivalent fantasy_league_game_limits rows with the same computed match cap. |
| Legacy draft tables | Schema-v2 draft_settings, draft_order, and draft_picks rows represent the same pick, turn, and completion state. |
The parity baseline remains the legacy route's observable behavior. A physical-table difference listed above is an approved schema-v2 replacement, not a regression.
Public join service contract:
PublicLeagueService.join({ userId, format, season, allowCreate: false }).200 body.null. On the first request (triggered omitted or false), the adapter triggers public-league-pool and returns the existing 202 pending body.triggered: true returns the existing 409 body.allowCreate is false.Format-availability HTTP contract:
{
"formats": {
"MID": { "available": true },
"FULL": { "available": false, "message": "This format isn't available for the current season: fewer than 8 matchdays remain across all four major leagues." }
}
}
GET /api/fantasy-leagues/format-availability always resolves the current server season, calls LeagueService.checkFormatAvailability, maps CAMPAIGN_8 to MID and REST_OF_SEASON to FULL, and returns 200. Invalid methods return 405; unexpected failures return 500 { error: "Failed to check format availability" }.
Typed error mapping rule:
| Error family | Expected mapping |
|---|---|
| Auth/membership failures | 401, 403, or 404 exactly as captured by parity for the route. |
| Request validation failures | 400 with the route's existing JSON shape. |
| Domain rule failures | Usually 400; use 403, 404, or 409 only when the current route already does or the ticket explicitly changes it. |
| Unexpected errors | Existing 500 shape for that route. |
Parity tests must prove user-visible compatibility across the migration seam, not just service unit correctness.
Each migrated endpoint needs parity coverage for:
Use existing specs as the baseline pattern:
| Spec | What it proves |
|---|---|
src/__tests__/parity/roster-update-happy.parity.spec.ts |
In-process, date-faked happy path with response and roster/history writes. |
src/__tests__/parity/roster-update.parity.spec.ts |
HTTP locked-window guard with status and error body. |
src/__tests__/parity/draft.parity.spec.ts |
HTTP manual pick plus auto-pick with draft and roster DB side effects. |
src/__tests__/parity/league-create-http.parity.spec.ts |
HTTP create-league happy path with response and DB side effects. |
src/__tests__/parity/full-flow.parity.spec.ts |
In-process create -> draft -> roster -> scoring smoke flow with side effects stubbed. |
Test execution rules:
bun run dev; the dev server is already managed outside this task. HTTP parity specs should stay skippable when PARITY_TEST_EMAIL, PARITY_TEST_PASSWORD, or ALLOW_EMULATED_CLOCK are missing.Minimum parity matrix per endpoint:
| Endpoint | Required parity cases |
|---|---|
/api/roster/update |
200 active-window success; 400 locked/completed/processing window; 400 invalid team count; capture the legacy duplicate/malformed-selection result before changing it; 404 missing fantasy team; no mutation on rejected windows. |
/api/draft/:leagueId/pick |
200 pick success; 400 missing teamId; 403 non-participant; 403 wrong turn; 404 missing draft/team; duplicate or race behavior captured before migration. |
/api/draft/:leagueId/auto-pick |
200 auto-pick success; 400 auto-pick disabled/no eligible team; 403 non-participant; 404 missing draft/order/current user; completion response when final pick is made. |
/api/draft/:leagueId/complete |
200 owner success with success and redirectUrl; 403 non-owner; 404 missing league; DB fields status, draftCompleted, and updatedAt. |
/api/fantasy-leagues/create |
201 success shape; 400 missing fields; 400 invalid format/team count/date/season window; 401 unauthenticated; DB rows for league, owner team, participant, matchweek windows, game limits, and draft settings. |
/api/fantasy-leagues/public/join |
200 joined shape; 202 pending pool creation shape; 409 no league after triggered retry; 400 invalid format; 401 unauthenticated; member rows and no duplicate membership on retry. |
/api/fantasy-leagues/format-availability |
Public 200 response matches the defined formats.MID/formats.FULL shape; unavailable message when fewer than eight matchdays remain; unavailable state when major competitions are missing; 405 for non-GET. |
Paste this checklist into every endpoint ticket:
requireAuth(locals.user) guard. Do not use page redirects in API routes.bun run build before handing off if code changed.Use KLUB-243 /api/roster/update as the copyable reference once it lands. KLUB-244, KLUB-246, KLUB-247, and KLUB-254 must not choose their own route-auth or service-error conventions before that reference is merged.
The reference implementation must demonstrate:
requireAuth(locals.user) helper used by every migrated protected endpoint and proves unauthenticated 401 { error: "Unauthorized" } behavior.locals.user, validates payload, checks the user's fantasy team/window access, calls the roster service, maps typed errors, and returns JSON.window_lineups writes inside the transaction.roster-update-happy.parity.spec.ts and roster-update.parity.spec.ts.window_lineups.Do not copy the current pre-KLUB-243 inline /api/roster/update implementation. Copy the post-KLUB-243 adapter/service split.