Route and Activity existed three times: hand-written interfaces in packages/types, Zod contracts in packages/api, and Drizzle columns in packages/db — each with different fields and nullability. The hand-written ones had drifted so far they had zero importers; the Zod contracts were advisory because v1 handlers hand-rolled Response.json shapes nothing validated. - packages/types keeps only what both apps actually share (Waypoint, WaypointPoiTags) and documents where row types and wire contracts live; the dead Route/RouteMetadata/RouteVersion/Activity interfaces are gone - packages/db exports canonical inferred row types (RouteRow, ActivityRow, RouteVersionRow, UserRow) - packages/api contracts are reconciled with the real wire format (RouteVersionSchema gains the id and createdBy fields the endpoint has always returned) and gain Create*ResponseSchemas - apiJson(schema, payload) in api-guard parses every v1 response through its contract: drift is now a thrown ZodError in tests/CI, unknown keys are stripped, and payloads are compile-checked as z.input of the schema Enforcement immediately caught two real drifts: nullable DB descriptions could ship null where the contract promises string (now coalesced at the boundary), and GET /api/v1/activities/:id was missing the routeName and photos fields its contract declares. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
55 lines
1.9 KiB
TypeScript
55 lines
1.9 KiB
TypeScript
import type { z } from "zod";
|
|
import { getAuthenticatedUser } from "./oauth.server.ts";
|
|
import { TERMS_VERSION } from "./legal.ts";
|
|
import { ERROR_CODES } from "@trails-cool/api";
|
|
|
|
/**
|
|
* Require authentication for an API route. Returns the user or throws a
|
|
* Response: 401 if unauthenticated, 403 with `TERMS_OUTDATED` if the user's
|
|
* stored `terms_version` is missing or stale relative to the current
|
|
* `TERMS_VERSION`. Mirrors the cookie-session terms gate enforced by the
|
|
* root loader, so bearer-token API traffic can't bypass it.
|
|
*/
|
|
export async function requireApiUser(request: Request) {
|
|
const user = await getAuthenticatedUser(request);
|
|
if (!user) {
|
|
throw Response.json(
|
|
{ error: "Unauthorized", code: ERROR_CODES.UNAUTHORIZED },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
if (user.termsVersion !== TERMS_VERSION) {
|
|
throw Response.json(
|
|
{
|
|
error: "Terms of Service have been updated and must be re-accepted",
|
|
code: ERROR_CODES.TERMS_OUTDATED,
|
|
currentTermsVersion: TERMS_VERSION,
|
|
},
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Return a structured API error response.
|
|
*/
|
|
export function apiError(status: number, code: string, message: string, fields?: Array<{ field: string; message: string }>) {
|
|
return Response.json({ error: message, code, fields }, { status });
|
|
}
|
|
|
|
/**
|
|
* Respond with a payload validated against its @trails-cool/api
|
|
* contract. The schema is enforced, not advisory: a handler whose
|
|
* payload drifts from the contract fails its unit tests / e2e run with
|
|
* a ZodError instead of silently shipping a different wire shape.
|
|
* Parsing also strips unknown keys, so the response is exactly the
|
|
* contract — nothing extra leaks.
|
|
*/
|
|
export function apiJson<S extends z.ZodType>(
|
|
schema: S,
|
|
payload: z.input<S>,
|
|
init?: ResponseInit,
|
|
): Response {
|
|
return Response.json(schema.parse(payload), init);
|
|
}
|