en un clic
api-endpoint-review-checklist
// 10-point checklist for reviewing new API endpoints in multi-user, dual-provider (PostgreSQL/SQLite) contexts
// 10-point checklist for reviewing new API endpoints in multi-user, dual-provider (PostgreSQL/SQLite) contexts
| name | api-endpoint-review-checklist |
| description | 10-point checklist for reviewing new API endpoints in multi-user, dual-provider (PostgreSQL/SQLite) contexts |
| domain | api-design, security, validation |
| confidence | low |
| source | observed |
When reviewing new API endpoints in this project, check for:
This checklist emerged from reviewing commit 398a7690 (Profile, Speech, Maintenance endpoints).
Every endpoint that accepts a resource ID (profile, resource, import, etc.) MUST verify the authenticated user owns that resource BEFORE operating on it.
Pattern:
var userProfileId = user.FindFirstValue("user_profile_id");
if (string.IsNullOrEmpty(userProfileId))
return Results.Unauthorized();
// Check ownership BEFORE fetching resource
if (!string.Equals(userProfileId, profileId, StringComparison.Ordinal))
return Results.Forbid();
var resource = await db.Resources.FirstOrDefaultAsync(r => r.Id == resourceId);
Anti-pattern:
// ❌ WRONG — fetches resource first, THEN checks ownership
var resource = await repository.GetByIdAsync(resourceId);
if (resource.UserProfileId != userProfileId)
return Results.Forbid();
Why order matters: The ownership check is cheap (string comparison). If you fetch the resource first, you've already done expensive DB work for a request you're going to reject.
NEVER use repository.ListAsync().FirstOrDefault(predicate) in API endpoints.
Why: Fetches ALL rows from the table into memory, then filters client-side. Performance bomb at scale.
Detection:
grep -r "\.ListAsync().*\.FirstOrDefault" src/SentenceStudio.Api
Fix: Use direct DB query scoped by ID:
// ❌ WRONG
var profile = (await repository.ListAsync()).FirstOrDefault(p => p.Id == profileId);
// ✅ CORRECT
var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.Id == profileId);
All request bodies MUST be validated. Use TypedResults.ValidationProblem for structured error responses.
Pattern:
if (string.IsNullOrWhiteSpace(request.DisplayName))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> {
{ nameof(request.DisplayName), new[] { "Display name is required" } }
});
if (request.PreferredSessionMinutes < 1 || request.PreferredSessionMinutes > 480)
return TypedResults.ValidationProblem(new Dictionary<string, string[]> {
{ nameof(request.PreferredSessionMinutes), new[] { "Session minutes must be between 1 and 480" } }
});
Anti-pattern:
// ❌ WRONG — commit message claims validation, code has none
// Accepts empty strings, negative numbers, malformed emails
Existing precedent: FeedbackEndpoints.cs:50-54 validates length + emptiness (uses BadRequest strings, not ideal, but better than nothing).
All async endpoint handlers MUST accept CancellationToken cancellationToken and pass it to repository/service calls.
Pattern:
private static async Task<IResult> GetProfile(
string profileId,
ClaimsPrincipal user,
[FromServices] UserProfileRepository repository,
CancellationToken cancellationToken) // ← Required
{
var profile = await repository.GetByIdAsync(profileId, cancellationToken);
return Results.Ok(MapToDto(profile));
}
Why: Without cancellation support, if the client drops the connection, the server keeps running the query. Wastes resources.
Existing precedent: FeedbackEndpoints.cs:42, ChannelEndpoints.cs, ImportEndpoints.cs all use CancellationToken.
All endpoints SHOULD log at key decision points (success, failure, ownership rejection).
Pattern:
private readonly ILogger<ProfileEndpoints> _logger;
// Constructor injection
public ProfileEndpoints(ILogger<ProfileEndpoints> logger) => _logger = logger;
// Usage
_logger.LogInformation("User {UserId} updated profile {ProfileId}", userProfileId, profileId);
_logger.LogWarning("Profile {ProfileId} not found for user {UserId}", profileId, userProfileId);
Existing precedent: FeedbackEndpoints.cs:44 injects ILoggerFactory.
Use structured TypedResults.Problem instead of plain Results.Problem strings.
Pattern:
if (saved < 0)
{
_logger.LogError("Profile save failed for user {UserId}", profileId);
return TypedResults.Problem(
title: "Save failed",
detail: "Unable to save profile changes. Please try again.",
statusCode: 500
);
}
Why: Provides consistent error shape for clients (title, detail, status, type).
Endpoints that operate on ALL users (e.g., maintenance/migration tasks) MUST have explicit admin-only authorization OR per-user filtering.
Anti-pattern:
// ❌ WRONG — MaintenanceEndpoints.cs:24-34
var userProfileId = user.FindFirstValue("user_profile_id"); // Extracted but NOT used
var migrated = await progressService.MigrateToStreakBasedScoringAsync(); // Operates on ALL users!
Fix (per-user):
var migrated = await progressService.MigrateToStreakBasedScoringAsync(userProfileId);
Fix (admin-only):
group.MapPost("/migrate-streak", MigrateStreak)
.RequireAuthorization("AdminOnly"); // Add policy
For endpoints supporting mobile clients (Flutter, MAUI), response shapes MUST be stable across versions.
Pattern:
dynamic or object)string? vs string)// ElevenLabsApiKey: Accepted in PUT but not yet persisted (always null in GET).
// Will be added to UserProfile schema in future migration.
ElevenLabsApiKey: null
All data modifications (POST/PUT/DELETE) MUST work correctly in BOTH PostgreSQL (API) and SQLite (mobile sync).
Check:
"PascalCase", SQLite is case-insensitive)Existing pattern: See .squad/skills/ef-dual-provider-migrations/SKILL.md.
Endpoints SHOULD follow existing route conventions:
/api/v1/{resource} (e.g., /api/channels)/api/v1/{resource}/{id} (e.g., /api/v1/profile/{profileId})/api/v1/{resource}/api/v1/{resource}/{id}/api/v1/{resource}/{id}Anti-pattern:
// MaintenanceEndpoints.cs — scoped to /api/v1/vocabulary/progress
// But it's a debug-only admin operation, not a vocabulary API surface
var group = app.MapGroup("/api/v1/vocabulary/progress").RequireAuthorization();
Better: /api/v1/maintenance or /api/v1/admin/maintenance.
Good endpoint (ChannelEndpoints.cs:22-32):
private static async Task<IResult> GetChannels(
ClaimsPrincipal user,
[FromServices] ChannelMonitorService channelService)
{
var userProfileId = user.FindFirstValue("user_profile_id");
if (string.IsNullOrEmpty(userProfileId))
return Results.Unauthorized();
var channels = await channelService.GetAllAsync(userProfileId); // ← Scoped by userId
return Results.Ok(channels);
}
Bad endpoint (ProfileEndpoints.cs:50-61):
private static async Task<IResult> GetProfile(
string profileId,
ClaimsPrincipal user,
[FromServices] UserProfileRepository repository)
{
var ownership = ResolveOwnership(profileId, user);
if (ownership is not null) return ownership;
var profile = (await repository.ListAsync()).FirstOrDefault(p => p.Id == profileId); // ← Fetches ALL profiles!
if (profile is null) return Results.NotFound();
return Results.Ok(MapToDto(profile));
}
repository.ListAsync().FirstOrDefault(predicate)SaveAsync returns int, endpoint returns unsaved entity# Find fetch-all anti-pattern
grep -r "\.ListAsync().*\.FirstOrDefault" src/SentenceStudio.Api
# Find endpoints without CancellationToken
grep -l "private static async Task<IResult>" src/SentenceStudio.Api/*.cs | \
xargs grep -L "CancellationToken"
# Find endpoints without logging
grep -l "private static async Task<IResult>" src/SentenceStudio.Api/*.cs | \
xargs grep -L "ILogger"
.squad/skills/ef-dual-provider-migrations/SKILL.md — PostgreSQL/SQLite migration patterns.squad/decisions/inbox/wash-fetch-all-antipattern.md — Decision record for this patternThis is the first observation of these patterns. Confidence will increase after:
End-to-end workflow for building, deploying, inspecting, and debugging .NET MAUI and MAUI Blazor Hybrid apps as an AI agent. Use when: (1) Building or running a MAUI app on iOS simulator, Android emulator, Mac Catalyst, macOS (AppKit), or Linux/GTK, (2) Inspecting or interacting with a running app's UI (visual tree, tapping, filling text, screenshots, property queries), (3) Debugging Blazor WebView content via CDP, (4) Managing simulators or emulators, (5) Setting up MauiDevFlow in a MAUI project, (6) Completing a build-deploy-inspect-fix feedback loop, (7) Handling permission dialogs and system alerts, (8) Managing multiple simultaneous apps via the broker daemon. Covers: maui devflow CLI (the `maui` dotnet global tool, `devflow` subcommand), androidsdk.tool, appledev.tools, adb, xcrun simctl, xdotool, and dotnet build/run for all MAUI target platforms including macOS (AppKit) and Linux/GTK.
Use this skill when the user is working with an Aspire distributed application and needs to operate the AppHost or its resources through the Aspire CLI: start, restart, stop, or wait on the app; work through code/resource changes with watch, rebuild, hot reload, or resource commands; inspect resources, logs, traces, docs, or health; add integrations; manage secrets or config; publish, deploy, or rerun a named pipeline step; initialize Aspire in an existing app; recover missing `.modules` files in a TypeScript AppHost; discover the right frontend URL for Playwright from Aspire state; expose custom dashboard/resource commands; or understand unfamiliar Aspire AppHost APIs in C# or TypeScript. Use it even if they describe the task in terms of an AppHost, resources, dashboard, existing app bootstrap, missing generated modules, Playwright URL discovery, C# API understanding, or local distributed app workflow without explicitly naming Aspire. Do not use it for non-Aspire .NET apps, container-only repos with no AppHo
{what this skill teaches agents}
{what this skill teaches agents}
How to tell whether a long-running background agent is hung vs. making progress, before reaching for stop_bash.
Copilot CLI skills and custom agents available in this environment