3042d289a0
Full list of the audit's confirmed findings and their fixes: - Stored XSS via unescaped JSON-LD on the public recipe page (app/r/[id]/page.tsx) — escape < before injecting. - CSP allowed unsafe-eval in production — now dev-only (Next prod never eval()s; only its HMR does). - avatarUrl accepted any URL with no ownership check — now takes an avatarKey issued by avatar-presign, validated server-side, same pattern as recipe/review photos. - No session revocation on password change/reset — both now revoke other sessions (revokeOtherSessions: true, revokeSessionsOnPasswordReset). - Rate-limit bypass via spoofable X-Forwarded-For — take the last (proxy-appended) hop instead of the first (client-supplied) one, matching the single-Traefik-hop topology. - Webhook signing secrets stored plaintext — now AES-256-GCM encrypted like every other secret in this app, with a legacy- plaintext fallback for pre-existing rows (bare hex has no ":", our ciphertext format always does). - Better Auth's own rate limiter defaulted to in-memory storage, ineffective across replicas — now backed by the same Redis as lib/rate-limit.ts (secondaryStorage), with storeSessionInDatabase explicit so session storage itself doesn't move as a side effect. - Presigned upload URLs didn't bind the declared file size to the actual upload, letting a client under-declare size (and quota charge) then PUT an arbitrarily large object — switched to S3 presigned POST with a signed content-length-range condition, enforced by the storage server itself. - generateMetadata() on the recipe page skipped the visibility filter the page body uses, leaking a private recipe's title via <title> to any signed-in user with the id. - Block/unblock had no rate limit, unlike follow/unfollow. - AI quota was charged even when a user's own BYOK key was used (their own credentials/billing) — added an isByok flag through the config-resolution chain and skip the charge when set. Also wired BYOK into generate/generate-from-idea/translate/import-url, which never looked it up at all before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
758 lines
123 KiB
TypeScript
758 lines
123 KiB
TypeScript
import {
|
|
OpenApiGeneratorV31,
|
|
OpenAPIRegistry,
|
|
extendZodWithOpenApi,
|
|
} from "@asteasolutions/zod-to-openapi";
|
|
import { z } from "zod";
|
|
import { USERNAME_PATTERN } from "@/lib/username";
|
|
|
|
// Nothing at module level — Next.js evaluates route modules at build time to
|
|
// determine static vs dynamic, which would run registry.register() before
|
|
// extendZodWithOpenApi patches the Zod prototype. Wrap everything lazily.
|
|
let cachedSpec: object | null = null;
|
|
|
|
export function generateOpenApiSpec(): object {
|
|
if (cachedSpec) return cachedSpec;
|
|
|
|
extendZodWithOpenApi(z);
|
|
const registry = new OpenAPIRegistry();
|
|
|
|
const security: Array<Record<string, string[]>> = [{ SessionCookie: [] }, { BearerApiKey: [] }];
|
|
|
|
registry.registerComponent("securitySchemes", "SessionCookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token" });
|
|
registry.registerComponent("securitySchemes", "BearerApiKey", { type: "http", scheme: "bearer", bearerFormat: "ek_..." });
|
|
|
|
const DietaryTagsRef = registry.register("DietaryTags", z.object({
|
|
vegan: z.boolean().optional(), vegetarian: z.boolean().optional(),
|
|
glutenFree: z.boolean().optional(), dairyFree: z.boolean().optional(),
|
|
nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(),
|
|
}));
|
|
|
|
const RecipeIngredientRef = registry.register("RecipeIngredient", z.object({
|
|
id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
|
|
unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int(),
|
|
}));
|
|
|
|
const RecipeStepRef = registry.register("RecipeStep", z.object({
|
|
id: z.string(), order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable(),
|
|
photoUrl: z.string().nullable(), appliesTo: z.array(z.string()),
|
|
}));
|
|
|
|
const RecipePhotoRef = registry.register("RecipePhoto", z.object({
|
|
id: z.string(), storageKey: z.string(), order: z.number().int(), isCover: z.boolean(),
|
|
}));
|
|
const RecipeBatchDishRef = registry.register("RecipeBatchDish", z.object({
|
|
id: z.string(), name: z.string(), description: z.string().nullable(), order: z.number().int(),
|
|
fridgeDays: z.number().int(), freezerFriendly: z.boolean(), freezerNote: z.string().nullable(),
|
|
dayOfInstructions: z.string(),
|
|
}));
|
|
|
|
const RecipeRef = registry.register("Recipe", z.object({
|
|
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
|
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
|
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
|
|
isBatchCook: z.boolean(), nutritionData: z.record(z.string(), z.unknown()).nullable(),
|
|
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
|
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
|
|
photos: z.array(RecipePhotoRef), batchDishes: z.array(RecipeBatchDishRef),
|
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
|
}));
|
|
|
|
const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
|
|
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
|
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
|
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
|
language: z.string().nullable(), sourceUrl: z.string().nullable(),
|
|
isBatchCook: z.boolean(), nutritionData: z.record(z.string(), z.unknown()).nullable(),
|
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
|
}).describe("Recipe list rows — no ingredients/steps/photos/batchDishes (unlike the single-recipe GET)."));
|
|
|
|
const CreateRecipeRef = registry.register("CreateRecipe", z.object({
|
|
title: z.string().min(1).max(200),
|
|
description: z.string().max(2000).optional(),
|
|
baseServings: z.number().int().min(1).max(100).default(4),
|
|
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
|
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
prepMins: z.number().int().min(0).max(1440).optional(),
|
|
cookMins: z.number().int().min(0).max(1440).optional(),
|
|
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
|
|
aiGenerated: z.boolean().optional(),
|
|
language: z.string().max(10).optional(),
|
|
sourceUrl: z.string().url().max(2000).optional(),
|
|
dietaryTags: DietaryTagsRef.optional(),
|
|
ingredients: z.array(z.object({
|
|
rawName: z.string().min(1).max(200),
|
|
quantity: z.union([z.number(), z.string()]).optional(),
|
|
unit: z.string().max(50).optional(),
|
|
note: z.string().max(500).optional(),
|
|
order: z.number().int().default(0),
|
|
})).max(100).default([]),
|
|
steps: z.array(z.object({
|
|
instruction: z.string().min(1).max(2000),
|
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
|
order: z.number().int().optional(),
|
|
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
|
|
})).max(100).default([]),
|
|
photos: z.array(z.object({
|
|
key: z.string().min(1).max(500),
|
|
isCover: z.boolean().default(false),
|
|
})).max(20).default([]),
|
|
isBatchCook: z.boolean().default(false),
|
|
dishes: z.array(z.object({
|
|
name: z.string().min(1).max(100),
|
|
description: z.string().max(500).optional(),
|
|
fridgeDays: z.number().int().min(1).max(14),
|
|
freezerFriendly: z.boolean().default(false),
|
|
freezerNote: z.string().max(300).optional(),
|
|
dayOfInstructions: z.string().min(1).max(1000),
|
|
})).max(10).default([]),
|
|
}));
|
|
|
|
const UpdateRecipeRef = registry.register("UpdateRecipe", z.object({
|
|
title: z.string().min(1).max(200).optional(),
|
|
description: z.string().max(2000).nullable().optional(),
|
|
baseServings: z.number().int().min(1).max(100).optional(),
|
|
recipeType: z.enum(["dish", "drink"]).optional(),
|
|
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
|
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
|
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
|
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
|
language: z.string().max(10).nullable().optional(),
|
|
sourceUrl: z.string().url().max(2000).nullable().optional(),
|
|
dietaryTags: DietaryTagsRef.optional(),
|
|
ingredients: z.array(z.object({
|
|
rawName: z.string().min(1).max(200),
|
|
quantity: z.union([z.number(), z.string()]).optional(),
|
|
unit: z.string().max(50).optional(),
|
|
note: z.string().max(500).optional(),
|
|
order: z.number().int().default(0),
|
|
})).max(100).optional(),
|
|
steps: z.array(z.object({
|
|
instruction: z.string().min(1).max(2000),
|
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
|
order: z.number().int(),
|
|
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
|
|
})).max(100).optional(),
|
|
photos: z.array(z.object({
|
|
key: z.string().min(1).max(500),
|
|
isCover: z.boolean().default(false),
|
|
})).max(20).optional(),
|
|
isBatchCook: z.boolean().optional(),
|
|
dishes: z.array(z.object({
|
|
name: z.string().min(1).max(100),
|
|
description: z.string().max(500).optional(),
|
|
fridgeDays: z.number().int().min(1).max(14),
|
|
freezerFriendly: z.boolean().default(false),
|
|
freezerNote: z.string().max(300).optional(),
|
|
dayOfInstructions: z.string().min(1).max(1000),
|
|
})).max(10).optional(),
|
|
}));
|
|
|
|
const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));
|
|
|
|
const CommentRef = registry.register("Comment", z.object({
|
|
id: z.string(), userId: z.string(),
|
|
content: z.string(), parentId: z.string().nullable(),
|
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
|
userName: z.string().nullable(), userUsername: z.string().nullable(), userAvatarUrl: z.string().nullable(),
|
|
}));
|
|
|
|
const CollectionRecipeEntryRef = registry.register("CollectionRecipeEntry", z.object({
|
|
collectionId: z.string(), recipeId: z.string(), addedAt: z.string().datetime(),
|
|
recipe: RecipeRef,
|
|
}));
|
|
const CollectionRef = registry.register("Collection", z.object({
|
|
id: z.string(), userId: z.string(), name: z.string(),
|
|
description: z.string().nullable(), isPublic: z.boolean(),
|
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
|
recipes: z.array(CollectionRecipeEntryRef),
|
|
}));
|
|
|
|
const MealPlanFullEntryRef = registry.register("MealPlanFullEntry", z.object({
|
|
id: z.string(), mealPlanId: z.string(),
|
|
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
|
|
mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
|
|
recipeId: z.string().nullable(), batchDishId: z.string().nullable(),
|
|
servings: z.number().int(), note: z.string().nullable(),
|
|
recipe: RecipeRef.nullable(),
|
|
}));
|
|
const MealPlanRef = registry.register("MealPlan", z.object({
|
|
id: z.string().optional(), userId: z.string().optional(), weekStart: z.string(),
|
|
createdAt: z.string().datetime().optional(),
|
|
entries: z.array(MealPlanFullEntryRef),
|
|
}));
|
|
|
|
const PantryItemRef = registry.register("PantryItem", z.object({
|
|
id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
|
|
unit: z.string().nullable(), expiresAt: z.string().datetime().nullable(),
|
|
}));
|
|
|
|
const ShoppingListRef = registry.register("ShoppingList", z.object({
|
|
id: z.string(), userId: z.string().nullable(), name: z.string(), isPublic: z.boolean(), publicEditable: z.boolean(),
|
|
generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
|
|
items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean(), inPantry: z.boolean(), sortOrder: z.number().int() })),
|
|
}));
|
|
|
|
const ApiKeyRef = registry.register("ApiKey", z.object({
|
|
id: z.string(), name: z.string(), scope: z.enum(["full", "read"]), lastUsedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
|
|
}));
|
|
|
|
const CreateApiKeyResponseRef = registry.register("CreateApiKeyResponse", z.object({
|
|
id: z.string(), name: z.string(), scope: z.enum(["full", "read"]), key: z.string().describe("Full key — shown once"), createdAt: z.string().datetime(),
|
|
}));
|
|
|
|
const AiGeneratedRef = registry.register("AiGeneratedRecipe", z.object({
|
|
title: z.string(), description: z.string(), baseServings: z.number(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]),
|
|
prepMins: z.number().nullable(), cookMins: z.number().nullable(),
|
|
dietaryTags: DietaryTagsRef,
|
|
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable() })),
|
|
steps: z.array(z.object({ instruction: z.string(), timerSeconds: z.number().nullable() })),
|
|
}));
|
|
|
|
const PaginatedRecipes = z.object({
|
|
data: z.array(RecipeSummaryRef),
|
|
limit: z.number(),
|
|
offset: z.number(),
|
|
});
|
|
|
|
const PaginatedCollections = z.object({
|
|
data: z.array(CollectionRef),
|
|
total: z.number(),
|
|
limit: z.number(),
|
|
offset: z.number(),
|
|
});
|
|
|
|
const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) });
|
|
|
|
const idParam = z.object({ id: z.string() });
|
|
const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}", summary: "Delete recipe", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/favorite", summary: "Favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}/favorite", summary: "Un-favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate/review a recipe (1-5, optional text + photo)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5), reviewText: z.string().max(2000).optional(), photoKey: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "Rating updated", content: { "application/json": { schema: z.object({ updated: z.literal(true) }) } } }, 201: { description: "Rating created", content: { "application/json": { schema: z.object({ created: z.literal(true) }) } } }, 400: { description: "Validation error or cannot rate your own recipe", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.object({ data: z.array(CommentRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", description: "Rate-limited: 20 req/min.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Recipes: bulk, fork, cooked, nutrition, notes, reviews, versions, comment reactions ---
|
|
const BulkIdsRef = registry.register("BulkIds", z.object({ ids: z.array(z.string()).min(1).max(100) }));
|
|
const BulkUpdateRecipesRef = registry.register("BulkUpdateRecipes", z.object({
|
|
ids: z.array(z.string()).min(1).max(100),
|
|
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
|
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
|
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
|
}));
|
|
const RecipeNoteRef = registry.register("RecipeNote", z.object({ content: z.string(), updatedAt: z.string().datetime() }).nullable());
|
|
const RecipeReviewRef = registry.register("RecipeReview", z.object({
|
|
id: z.string(), score: z.number().int(), reviewText: z.string().nullable(), photoKey: z.string().nullable(),
|
|
createdAt: z.string().datetime(),
|
|
user: z.object({ id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
|
|
}));
|
|
const RecipeVersionSummaryRef = registry.register("RecipeVersionSummary", z.object({
|
|
id: z.string(), version: z.number().int(), title: z.string(), createdAt: z.string().datetime(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "delete", path: "/api/v1/recipes/bulk", summary: "Bulk delete recipes (owned only)", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional() }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()).nullable() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- AI (Phase 3): recipe generation, transforms, chat ---
|
|
const AiChatMessageRef = registry.register("AiChatMessage", z.object({
|
|
id: z.string(), role: z.enum(["user", "assistant"]), content: z.string(),
|
|
createdAt: z.string().datetime(), recipeId: z.string().nullable(), recipeTitle: z.string().nullable(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/generate-from-idea", summary: "Generate a full recipe from a short title/idea", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota. Written in the caller's app language, regardless of the language shown in the photo.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 422: { description: "No recipe recognized in the photo", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/substitute", summary: "Suggest ingredient substitutions", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ ingredient: z.string().min(1).max(200), recipeTitle: z.string().max(200).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Substitutions", content: { "application/json": { schema: z.object({ substitutions: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/scale", summary: "Scale a recipe's ingredients to a target serving count", description: "Rate-limited: 20 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string(), targetServings: z.number().int().min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "Scaled ingredients", content: { "application/json": { schema: z.object({ ingredients: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/adapt/{id}", summary: "Adapt a recipe to exclude ingredients or meet extra constraints", description: "Consumes AI quota. Creates a new private draft recipe.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ excludeIngredients: z.array(z.string()).default([]), extraConstraints: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id and adaptation notes", content: { "application/json": { schema: z.object({ id: z.string(), adaptationNotes: z.string().optional() }) } } }, 400: { description: "Validation error or no constraint provided", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/drinks/{id}", summary: "Suggest drink pairings for a recipe", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(6).default(4), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Drinks", content: { "application/json": { schema: z.object({ drinks: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/pairings/{id}", summary: "Suggest food pairings/side dishes for a recipe", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(6).default(4), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Pairings", content: { "application/json": { schema: z.object({ pairings: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/translate/{id}", summary: "Translate a recipe (author only) and save as a new draft", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ targetLanguage: z.string().min(2).max(50), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/variations/{id}", summary: "Suggest creative variations on a recipe (author only)", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(5).default(3), directions: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Variations", content: { "application/json": { schema: z.object({ variations: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-chat", summary: "Ask a question about a specific recipe (author only)", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string().uuid(), question: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Recipe not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/ai/chat-history", summary: "List your AI chat history (general assistant and/or per-recipe)", description: "Omit both recipeId and scope to search across everything; pass scope=general to restrict to the homepage assistant (no recipe).", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), scope: z.enum(["general"]).optional(), q: z.string().max(200).optional(), limit: z.coerce.number().int().min(1).max(100).default(30) }) }, responses: { 200: { description: "Messages", content: { "application/json": { schema: z.object({ data: z.array(AiChatMessageRef) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/ai/chat-history", summary: "Clear your AI chat history (general assistant and/or per-recipe)", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), scope: z.enum(["general"]).optional() }) }, responses: { 200: { description: "Cleared", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
const FeedRecipeRef = registry.register("FeedRecipe", z.object({
|
|
id: z.string(), title: z.string(), description: z.string().nullable(),
|
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
|
|
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
|
}));
|
|
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
const CreateCollectionRef = registry.register("CreateCollection", z.object({
|
|
name: z.string().min(1).max(100), description: z.string().max(500).optional(), isPublic: z.boolean().default(false),
|
|
}));
|
|
registry.registerPath({ method: "post", path: "/api/v1/collections", summary: "Create a collection", security, request: { body: { content: { "application/json": { schema: CreateCollectionRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Collections: single-resource CRUD, fork, favorite, members ---
|
|
const CollectionMemberRef = registry.register("CollectionMember", z.object({
|
|
id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
|
|
user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
|
|
}));
|
|
const UpdateCollectionRef = registry.register("UpdateCollection", z.object({
|
|
name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), isPublic: z.boolean().optional(),
|
|
addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(),
|
|
removeRecipeId: z.string().optional(), removeRecipeIds: z.array(z.string()).max(200).optional(),
|
|
}));
|
|
const InviteMemberRef = registry.register("InviteMember", z.object({
|
|
email: z.string().email().optional(), userId: z.string().optional(), role: z.enum(["viewer", "editor"]),
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/collections/{id}", summary: "Get a collection (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Collection with recipes", content: { "application/json": { schema: CollectionRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/collections/{id}", summary: "Update a collection / add-remove recipes (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateCollectionRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}", summary: "Delete a collection (owner only)", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/fork", summary: "Fork a public (or your own) collection", security, request: { params: idParam }, responses: { 201: { description: "New collection id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/favorite", summary: "Favorite a collection", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/favorite", summary: "Unfavorite a collection", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/collections/{id}/members", summary: "List collection members (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(CollectionMemberRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/members", summary: "Invite a member by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/members", summary: "Remove a member (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get your own meal plan for a week", security, request: { params: weekStartParam }, responses: { 200: { description: "Meal plan with entries", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}", summary: "Create the meal plan for a week (idempotent)", security, request: { params: weekStartParam }, responses: { 200: { description: "Already existed", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } }, 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } } } });
|
|
|
|
// --- Meal-plan entries, bulk clear, members, nutrition, .ics export, shared plans ---
|
|
const CreateMealPlanEntryRef = registry.register("CreateMealPlanEntry", z.object({
|
|
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
|
|
mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
|
|
recipeId: z.string().optional(), batchDishId: z.string().optional(),
|
|
servings: z.number().int().min(1).max(100).default(2), note: z.string().max(500).optional(),
|
|
}));
|
|
const MealPlanEntryLeanRef = registry.register("MealPlanEntryLean", z.object({
|
|
id: z.string(), day: z.string(), mealType: z.string(), servings: z.number(),
|
|
recipe: z.object({ id: z.string(), title: z.string() }).nullable(),
|
|
batchDish: z.object({ id: z.string(), name: z.string() }).nullable().optional(),
|
|
note: z.string().nullable().optional(),
|
|
}));
|
|
const MealPlanMemberRef = registry.register("MealPlanMember", z.object({
|
|
id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
|
|
user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
|
|
}));
|
|
const NutritionTotalsRef = registry.register("NutritionTotals", z.object({ calories: z.number(), protein: z.number(), carbs: z.number(), fat: z.number() }));
|
|
const WeeklyNutritionRef = registry.register("WeeklyNutrition", z.object({
|
|
totals: NutritionTotalsRef, dailyAverage: NutritionTotalsRef,
|
|
byDay: z.record(z.string(), NutritionTotalsRef), plannedDays: z.number().int(), unknownCount: z.number().int(),
|
|
goals: z.object({ caloriesKcal: z.number().nullable(), proteinG: z.number().nullable(), carbsG: z.number().nullable(), fatG: z.number().nullable() }).nullable(),
|
|
coverage: NutritionTotalsRef,
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/entries", summary: "List entries for a week (lean — polled for live updates)", security, request: { params: weekStartParam }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/entries", summary: "Add/replace an entry for a day+mealType slot", description: "Creates the week's plan if it doesn't exist yet. Replaces any existing entry in the same slot.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: CreateMealPlanEntryRef } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Recipe or dish not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/{entryId}", summary: "Delete one entry", security, request: { params: z.object({ weekStart: z.string(), entryId: z.string() }) }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/bulk", summary: "Bulk-delete entries (clear a day or the whole week)", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/members", summary: "List collaborators on a week's plan (owner only)", security, request: { params: weekStartParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(MealPlanMemberRef) } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Invite a collaborator by email or userId (owner only)", description: "Creates the week's plan if it doesn't exist yet.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string(), mealPlanId: z.string() }) } } }, 400: { description: "Cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: weekStartParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/nutrition", summary: "Weekly nutrition summary vs. your daily goals", security, request: { params: weekStartParam }, responses: { 200: { description: "Summary", content: { "application/json": { schema: WeeklyNutritionRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/export/ics", summary: "Download the week's plan as a .ics calendar file", security, request: { params: weekStartParam }, responses: { 200: { description: "iCalendar file", content: { "text/calendar": { schema: z.string() } } }, 400: { description: "Invalid weekStart", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}", summary: "View a meal plan you're a collaborator on", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Plan with entries, your role, and the owner's name", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "List entries on a shared plan (lean — polled for live updates)", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Add/replace an entry on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), body: { content: { "application/json": { schema: CreateMealPlanEntryRef.omit({ batchDishId: true }) } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found / recipe not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Delete one or more entries on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), query: z.object({ entryId: z.string().optional(), ids: z.string().optional().describe("comma-separated entry ids, for clearing a day/week") }) }, responses: { 204: { description: "Deleted" }, 400: { description: "entryId or ids required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ data: z.array(PantryItemRef), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/pantry", summary: "Add a pantry item", security, request: { body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().optional(), expiresAt: z.string().datetime().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/pantry/{id}", summary: "Update a pantry item", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200).optional(), quantity: z.string().nullable().optional(), unit: z.string().nullable().optional(), expiresAt: z.string().datetime().nullable().optional() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/pantry/{id}", summary: "Delete a pantry item", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/pantry/bulk", summary: "Add several pantry items at once, merging quantities into existing matching items", security, request: { body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().max(50).optional() })).min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/barcode", summary: "Look up a barcode via Open Food Facts to prefill a pantry item", description: "Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/) }) } }, required: true } }, responses: { 200: { description: "Lookup result", content: { "application/json": { schema: z.object({ found: z.boolean(), rawName: z.string().optional(), quantity: z.string().optional(), unit: z.string().optional() }) } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } }, 502: { description: "Lookup service unavailable", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/photo", summary: "Identify pantry items from a photo using AI vision", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Detected items", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Users: profile, export, prefs, nutrition, social ---
|
|
const usernameParam = z.object({ username: z.string() });
|
|
const UpdateMeRef = registry.register("UpdateMe", z.object({
|
|
name: z.string().min(1).max(100).optional(), locale: z.string().max(10).optional(),
|
|
bio: z.string().max(500).nullable().optional(), privateBio: z.string().max(2000).nullable().optional(),
|
|
isPrivate: z.boolean().optional(), username: z.string().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(),
|
|
avatarKey: z.string().max(500).nullable().optional().describe("Storage key returned by POST /upload/avatar-presign, not a URL — validated server-side against the caller's own uploads."), useGravatar: z.boolean().optional(),
|
|
}));
|
|
const ModelPrefsRef = registry.register("ModelPrefs", z.object({
|
|
id: z.string(), userId: z.string(),
|
|
textProvider: z.string().nullable(), textModel: z.string().nullable(),
|
|
visionProvider: z.string().nullable(), visionModel: z.string().nullable(),
|
|
mealPlanProvider: z.string().nullable(), mealPlanModel: z.string().nullable(),
|
|
updatedAt: z.string().datetime(),
|
|
}));
|
|
const UpdateModelPrefsRef = registry.register("UpdateModelPrefs", z.object({
|
|
textProvider: z.string().nullable().optional(), textModel: z.string().nullable().optional(),
|
|
visionProvider: z.string().nullable().optional(), visionModel: z.string().nullable().optional(),
|
|
mealPlanProvider: z.string().nullable().optional(), mealPlanModel: z.string().nullable().optional(),
|
|
}));
|
|
const NotificationPrefsRef = registry.register("NotificationPrefs", z.object({
|
|
follow: z.boolean(), comment: z.boolean(), reply: z.boolean(), reaction: z.boolean(),
|
|
rating: z.boolean(), mention: z.boolean(), leftoverExpiring: z.boolean(), shoppingList: z.boolean(),
|
|
}));
|
|
const UpdateNotificationPrefsRef = registry.register("UpdateNotificationPrefs", z.object({
|
|
follow: z.boolean().optional(), comment: z.boolean().optional(), reply: z.boolean().optional(), reaction: z.boolean().optional(),
|
|
rating: z.boolean().optional(), mention: z.boolean().optional(), leftoverExpiring: z.boolean().optional(), shoppingList: z.boolean().optional(),
|
|
}));
|
|
const NutritionGoalsRef2 = registry.register("NutritionGoalsMe", z.object({
|
|
id: z.string(), userId: z.string(),
|
|
caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(),
|
|
carbsG: z.number().int().nullable(), fatG: z.number().int().nullable(),
|
|
updatedAt: z.string().datetime(),
|
|
}).nullable());
|
|
const UpdateNutritionGoalsRef = registry.register("UpdateNutritionGoals", z.object({
|
|
caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(),
|
|
carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(),
|
|
}));
|
|
const NutritionDiaryEntryRef = registry.register("NutritionDiaryEntry", z.object({
|
|
id: z.string(), recipeId: z.string(), title: z.string(), servings: z.number(),
|
|
cookedAt: z.string().datetime(), nutritionKnown: z.boolean(),
|
|
}));
|
|
const NutritionTotalsExtRef = registry.register("NutritionTotalsExt", z.object({
|
|
calories: z.number(), proteinG: z.number(), carbsG: z.number(), fatG: z.number(), fiberG: z.number(), sodiumMg: z.number(),
|
|
}));
|
|
const NutritionGoalsSummaryRef = registry.register("NutritionGoalsSummary", z.object({
|
|
caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(), carbsG: z.number().int().nullable(), fatG: z.number().int().nullable(),
|
|
}));
|
|
const NutritionDiaryRef = registry.register("NutritionDiary", z.object({
|
|
date: z.string(), totals: NutritionTotalsExtRef, goals: NutritionGoalsSummaryRef.nullable(),
|
|
coverage: NutritionTotalsRef, entries: z.array(NutritionDiaryEntryRef), unknownCount: z.number().int(),
|
|
}));
|
|
const UserSearchResultRef = registry.register("UserSearchResult", z.object({
|
|
id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
|
|
}));
|
|
const UserProfilePublicRef = registry.register("UserProfilePublic", z.object({
|
|
id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
|
|
createdAt: z.string().datetime(), followerCount: z.number().int(), followingCount: z.number().int(),
|
|
recipeCount: z.number().int(), isFollowing: z.boolean(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "patch", path: "/api/v1/users/me", summary: "Update your profile", description: "Toggling useGravatar or clearing avatarKey re-derives the effective avatar (Gravatar or initials fallback).", security, request: { body: { content: { "application/json": { schema: UpdateMeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), avatarUrl: z.string().nullable() }) } } }, 400: { description: "Validation error, or avatarKey wasn't issued to you by avatar-presign", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Username already taken", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/export", summary: "Export all of your account data as a JSON file", description: "Rate-limited: 3 req/hour. Includes profile, recipes, social graph, collections, meal plans, pantry, shopping lists, webhooks, API keys, messages, and more.", security, responses: { 200: { description: "Downloadable JSON export", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/model-prefs", summary: "Get your AI model provider/model preferences", security, responses: { 200: { description: "Preferences or null", content: { "application/json": { schema: ModelPrefsRef.nullable() } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/users/me/model-prefs", summary: "Set your AI model provider/model preferences", security, request: { body: { content: { "application/json": { schema: UpdateModelPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/notification-prefs", summary: "Get your notification category preferences", description: "Categories with no saved row default to enabled.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: NotificationPrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC)") }) }, responses: { 200: { description: "Diary", content: { "application/json": { schema: NutritionDiaryRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/users/{username}/follow", summary: "Unfollow a user", security, request: { params: usernameParam }, responses: { 200: { description: "Not following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/block", summary: "Block a user", description: "Also removes any existing follow relationship in either direction.", security, request: { params: usernameParam }, responses: { 200: { description: "Blocked", content: { "application/json": { schema: z.object({ blocked: z.boolean() }) } } }, 400: { description: "Cannot block yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/users/{username}/block", summary: "Unblock a user", security, request: { params: usernameParam }, responses: { 200: { description: "Unblocked", content: { "application/json": { schema: z.object({ blocked: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", description: "Returns all of your lists, unpaginated.", security, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", description: "Optionally seeded from a meal-plan week's merged ingredients (pantry-aware).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).optional(), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Shopping lists: single-resource CRUD, items, members, export ---
|
|
const ShoppingListItemRef = registry.register("ShoppingListItem", z.object({
|
|
id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(),
|
|
aisle: z.string().nullable(), checked: z.boolean(), sortOrder: z.number().int(),
|
|
}));
|
|
const UpdateShoppingListRef = registry.register("UpdateShoppingList", z.object({
|
|
completed: z.boolean().optional(), name: z.string().min(1).max(100).optional(),
|
|
isPublic: z.boolean().optional(), publicEditable: z.boolean().optional(),
|
|
}));
|
|
const AddShoppingListItemsRef = registry.register("AddShoppingListItems", z.object({
|
|
items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).min(1),
|
|
}));
|
|
const UpdateShoppingListItemRef = registry.register("UpdateShoppingListItem", z.object({
|
|
checked: z.boolean().optional(), rawName: z.string().min(1).optional(), quantity: z.string().nullable().optional(),
|
|
unit: z.string().nullable().optional(), aisle: z.string().nullable().optional(), sortOrder: z.number().int().optional(),
|
|
}));
|
|
const ShoppingListMemberRef = registry.register("ShoppingListMember", z.object({
|
|
id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
|
|
user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
|
|
}));
|
|
const GroceryExportRef = registry.register("GroceryExport", z.object({
|
|
listName: z.string(),
|
|
items: z.array(z.object({ name: z.string(), quantity: z.string().nullable(), unit: z.string().nullable() })),
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}", summary: "Get a shopping list with items", security, request: { params: idParam }, responses: { 200: { description: "List", content: { "application/json": { schema: ShoppingListRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/shopping-lists/{id}", summary: "Rename, mark complete, or toggle public link/public-editable", description: "Renaming, deleting, and toggling the public link are owner-only. Turning off the public link also revokes public editing.", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateShoppingListRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}", summary: "Delete a shopping list (owner only)", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/items", summary: "List items (lean — polled for live updates)", description: "No auth required if the list is public-editable — access is the link (id) itself.", security, request: { params: idParam }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ items: z.array(ShoppingListItemRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/items", summary: "Add items", description: "No auth required if the list is public-editable.", security, request: { params: idParam, body: { content: { "application/json": { schema: AddShoppingListItemsRef } }, required: true } }, responses: { 201: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/shopping-lists/{id}/items", summary: "Bulk update sortOrder/aisle (e.g. after drag-and-drop reorder)", description: "No auth required if the list is public-editable.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ id: z.string().min(1), sortOrder: z.number().int().optional(), aisle: z.string().nullable().optional() })).min(1) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "put", path: "/api/v1/shopping-lists/{id}/items/{itemId}", summary: "Update one item (check/uncheck, rename, re-quantify, move aisle)", description: "No auth required if the list is public-editable.", security, request: { params: z.object({ id: z.string(), itemId: z.string() }), body: { content: { "application/json": { schema: UpdateShoppingListItemRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/items/{itemId}", summary: "Delete one item", description: "No auth required if the list is public-editable.", security, request: { params: z.object({ id: z.string(), itemId: z.string() }) }, responses: { 200: { description: "Deleted", content: { "application/json": { schema: z.object({ deleted: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/members", summary: "List collaborators (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(ShoppingListMemberRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/members", summary: "Invite a collaborator by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/export", summary: "Export items in a normalized grocery-provider format", security, request: { params: idParam }, responses: { 200: { description: "Export payload", content: { "application/json": { schema: GroceryExportRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- AI keys: users' own third-party provider keys (BYOK), used instead of app-wide AI credentials ---
|
|
const AiKeyRef = registry.register("AiKey", z.object({
|
|
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), createdAt: z.string().datetime(),
|
|
}).describe("Never includes the encrypted key value itself — only which providers are configured."));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API.", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response.", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Webhooks: outbound event notifications to a user-provided URL, plus delivery log/redelivery ---
|
|
const WebhookEventEnum = z.enum(["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted", "meal_plan.updated", "shopping_list.completed", "comment.added"]);
|
|
const WebhookRef = registry.register("Webhook", z.object({
|
|
id: z.string(), userId: z.string(), url: z.string(), events: z.array(WebhookEventEnum), active: z.boolean(), createdAt: z.string().datetime(),
|
|
}));
|
|
const CreateWebhookRef = registry.register("CreateWebhook", z.object({
|
|
url: z.string().min(1).max(2048), events: z.array(WebhookEventEnum).default([]),
|
|
}));
|
|
const CreatedWebhookRef = registry.register("CreatedWebhook", z.object({
|
|
id: z.string(), userId: z.string(), url: z.string(), events: z.array(WebhookEventEnum),
|
|
secret: z.string().describe("Signing secret — shown only in this response, never returned again."),
|
|
active: z.boolean(), createdAt: z.string().datetime(),
|
|
}));
|
|
const UpdateWebhookRef = registry.register("UpdateWebhook", z.object({
|
|
url: z.string().min(1).max(2048).optional(), events: z.array(WebhookEventEnum).optional(), active: z.boolean().optional(),
|
|
}));
|
|
const WebhookDeliveryRef = registry.register("WebhookDelivery", z.object({
|
|
id: z.string(), webhookId: z.string(), event: z.string(), payload: z.record(z.string(), z.unknown()).nullable(),
|
|
statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/webhooks", summary: "List your webhooks", description: "The signing secret is never included — it is only ever returned once, at creation time.", security, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(WebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/webhooks", summary: "Create a webhook", description: "Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security, request: { body: { content: { "application/json": { schema: CreateWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/webhooks/{id}", summary: "Update a webhook's URL, events, or active state", description: "Validates the URL against SSRF targets if changed.", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: WebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/webhooks/{id}", summary: "Delete a webhook", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}/deliveries", summary: "List recent delivery attempts (most recent 20)", security, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(WebhookDeliveryRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Conversations: 1:1 direct messages ---
|
|
const ConversationSummaryRef = registry.register("ConversationSummary", z.object({
|
|
id: z.string(),
|
|
otherUser: z.object({ id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }).nullable(),
|
|
lastMessage: z.string().nullable(), lastMessageAt: z.string().datetime(), unreadCount: z.number(),
|
|
}));
|
|
const StartConversationRef = registry.register("StartConversation", z.object({
|
|
username: z.string().min(1), content: z.string().min(1).max(4000).optional(),
|
|
}));
|
|
const MessageRef = registry.register("Message", z.object({
|
|
id: z.string(), conversationId: z.string(), senderId: z.string(), content: z.string(), createdAt: z.string().datetime(),
|
|
}));
|
|
const SendMessageRef = registry.register("SendMessage", z.object({ content: z.string().min(1).max(4000) }));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/conversations", summary: "List your conversations, most recently active first", description: "Up to 50 conversations.", security, responses: { 200: { description: "Conversations", content: { "application/json": { schema: z.object({ conversations: z.array(ConversationSummaryRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/conversations", summary: "Start (or reuse) a conversation with a user by username, optionally sending a first message", description: "Rate-limited: 30 req/min. Fails (404) if either side has blocked the other, target doesn't exist, or you target yourself.", security, request: { body: { content: { "application/json": { schema: StartConversationRef } }, required: true } }, responses: { 201: { description: "Conversation id", content: { "application/json": { schema: z.object({ conversationId: z.string() }) } } }, 400: { description: "Validation error or cannot message yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or blocked", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/conversations/{id}/messages", summary: "List messages in a conversation (cursor-paginated, oldest-first per page)", description: "Loading the newest page (no `before`) also marks the conversation read.", security, request: { params: idParam, query: z.object({ before: z.string().datetime().optional().describe("ISO createdAt cursor — returns the 50 messages just before it") }) }, responses: { 200: { description: "Page of messages", content: { "application/json": { schema: z.object({ messages: z.array(MessageRef), nextCursor: z.string().nullable() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/conversations/{id}/messages", summary: "Send a message in a conversation", description: "Rate-limited: 30 req/min. Fails (404) if the other participant has blocked you or vice versa.", security, request: { params: idParam, body: { content: { "application/json": { schema: SendMessageRef } }, required: true } }, responses: { 201: { description: "Message id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Notifications ---
|
|
const NotificationRef = registry.register("Notification", z.object({
|
|
id: z.string(), type: z.enum(["follow", "comment", "reply", "reaction", "rating", "mention"]),
|
|
recipeId: z.string().nullable(), commentId: z.string().nullable(), read: z.boolean(), createdAt: z.string().datetime(),
|
|
actorId: z.string(), actorName: z.string(), actorUsername: z.string().nullable(), actorAvatarUrl: z.string().nullable(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/notifications", summary: "List your notifications, newest first", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated notifications", content: { "application/json": { schema: z.object({ notifications: z.array(NotificationRef), unreadCount: z.number(), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/notifications/{id}/read", summary: "Mark a single notification read", security, request: { params: idParam }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/notifications/read", summary: "Mark notifications read — one by id, or all of yours if omitted", security, request: { body: { content: { "application/json": { schema: z.object({ id: z.string().optional() }) } } } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Feed: trending & personalized ---
|
|
const TrendingRecipeRef = registry.register("TrendingRecipe", z.object({
|
|
id: z.string(), title: z.string(), description: z.string().nullable(),
|
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
|
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
|
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
|
favoriteCount: z.number().int(),
|
|
}));
|
|
const ForYouRecipeRef = registry.register("ForYouRecipe", z.object({
|
|
id: z.string(), title: z.string(), description: z.string().nullable(),
|
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
|
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
|
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/feed/trending", summary: "Trending public recipes (by favorites in the last 7 days)", description: "Public — no auth required. Excludes private authors.", security: [], request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(TrendingRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
|
|
registry.registerPath({ method: "get", path: "/api/v1/feed/for-you", summary: "Personalized recipe feed, ranked from your favorites and 4+ ratings", description: "Scores a bounded window of the 200 most recent eligible candidates rather than the whole table, so `total` reflects that window, not every eligible recipe.", security, request: { query: z.object({ limit: z.coerce.number().int().max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Ranked, paginated", content: { "application/json": { schema: z.object({ data: z.array(ForYouRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Search ---
|
|
const SearchResultRef = registry.register("SearchResult", z.object({
|
|
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
|
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
|
|
avgRating: z.number().nullable(), ratingCount: z.number().int(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Invites ---
|
|
registry.registerPath({ method: "get", path: "/api/v1/invites/{token}", summary: "Check whether an invite token is valid", description: "Public — no auth required.", security: [], request: { params: z.object({ token: z.string() }) }, responses: { 200: { description: "Validity (and the invited email, if valid)", content: { "application/json": { schema: z.object({ valid: z.boolean(), email: z.string().optional() }) } } } } });
|
|
|
|
// --- Uploads: S3 presigned URLs ---
|
|
const PresignUploadRef = registry.register("PresignUpload", z.object({
|
|
contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]),
|
|
recipeId: z.string().uuid(), purpose: z.enum(["recipe", "review"]).default("recipe"),
|
|
fileSize: z.number().int().positive().max(10 * 1024 * 1024, "File exceeds 10MB limit"),
|
|
}));
|
|
const PresignAvatarUploadRef = registry.register("PresignAvatarUpload", z.object({
|
|
contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]),
|
|
fileSize: z.number().int().positive().max(5 * 1024 * 1024, "File exceeds 5MB limit"),
|
|
}));
|
|
const PresignResponseRef = registry.register("PresignResponse", z.object({
|
|
url: z.string().describe("POST this URL as multipart/form-data — include every field below, then the file itself last."),
|
|
fields: z.record(z.string(), z.string()).describe("Form fields to include in the upload POST, including a signed content-length-range condition that the storage server enforces (the declared fileSize can't be lied about)."),
|
|
key: z.string(),
|
|
}));
|
|
|
|
registry.registerPath({ method: "post", path: "/api/v1/upload/presign", summary: "Get a presigned S3 upload POST (URL + form fields) for a recipe or review photo", description: "Recipe photos: recipeId must be your own recipe. Review photos: recipe must be visible to you. Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/upload/avatar-presign", summary: "Get a presigned S3 upload POST (URL + form fields) for your avatar photo", description: "Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignAvatarUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Push notifications ---
|
|
const PushSubscribeRef = registry.register("PushSubscribe", z.object({
|
|
endpoint: z.string().url(), keys: z.object({ p256dh: z.string(), auth: z.string() }),
|
|
}));
|
|
const PushUnsubscribeRef = registry.register("PushUnsubscribe", z.object({ endpoint: z.string() }));
|
|
|
|
registry.registerPath({ method: "post", path: "/api/v1/push/subscribe", summary: "Register (or update) a web push subscription for this device", description: "Session only — device-bound, no API key auth.", security: [{ SessionCookie: [] }], request: { body: { content: { "application/json": { schema: PushSubscribeRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid body", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/push/subscribe", summary: "Remove a web push subscription for this device", description: "Session only — device-bound, no API key auth.", security: [{ SessionCookie: [] }], request: { body: { content: { "application/json": { schema: PushUnsubscribeRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid body", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Reports ---
|
|
const CreateReportRef = registry.register("CreateReport", z.object({
|
|
targetType: z.enum(["recipe", "comment", "user"]), targetId: z.string().min(1), reason: z.string().min(1).max(1000),
|
|
}));
|
|
|
|
registry.registerPath({ method: "post", path: "/api/v1/reports", summary: "Report a recipe, comment, or user for moderation", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: CreateReportRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Target not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Comments: edit & delete ---
|
|
registry.registerPath({ method: "put", path: "/api/v1/comments/{id}", summary: "Edit your own comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000) }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or not yours", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/comments/{id}", summary: "Delete a comment (author, or an admin/moderator)", description: "Non-author deletes re-check the caller's role against the DB rather than the (up to 5-minute-stale) session cookie cache.", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
// --- Admin (Admin only. Session-cookie auth only — requireAdmin() has no API-key support) ---
|
|
const adminSecurity: Array<Record<string, string[]>> = [{ SessionCookie: [] }];
|
|
|
|
const InviteRef = registry.register("Invite", z.object({
|
|
id: z.string(), token: z.string(), email: z.string().nullable(),
|
|
role: z.enum(["user", "moderator", "admin"]), tier: z.enum(["free", "pro"]),
|
|
createdById: z.string(), createdAt: z.string().datetime(),
|
|
expiresAt: z.string().datetime().nullable(), usedAt: z.string().datetime().nullable(),
|
|
usedById: z.string().nullable(),
|
|
}));
|
|
const CreateInviteRef = registry.register("CreateInvite", z.object({
|
|
email: z.string().optional(), role: z.enum(["user", "moderator", "admin"]).default("user"),
|
|
tier: z.enum(["free", "pro"]).default("free"), expiresInDays: z.number().default(7),
|
|
}));
|
|
|
|
const AdminReportRef = registry.register("AdminReport", z.object({
|
|
id: z.string(), targetType: z.enum(["recipe", "comment", "user"]), targetId: z.string(),
|
|
reason: z.string(), status: z.enum(["pending", "reviewed", "dismissed"]),
|
|
createdAt: z.string().datetime(), reporterId: z.string(),
|
|
reporterName: z.string().nullable(), reporterEmail: z.string(),
|
|
}));
|
|
const ResolveReportBodyRef = registry.register("ResolveReportBody", z.object({
|
|
status: z.enum(["reviewed", "dismissed"]),
|
|
}));
|
|
const ResolvedReportRef = registry.register("ResolvedReport", z.object({
|
|
id: z.string(), reporterId: z.string(), targetType: z.enum(["recipe", "comment", "user"]),
|
|
targetId: z.string(), reason: z.string(), status: z.enum(["pending", "reviewed", "dismissed"]),
|
|
createdAt: z.string().datetime(), reviewedById: z.string().nullable(), reviewedAt: z.string().datetime().nullable(),
|
|
}));
|
|
|
|
const UpdateSiteSettingsRef = registry.register("UpdateSiteSettings", z.object({
|
|
OPENAI_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
|
|
ANTHROPIC_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
|
|
OPENROUTER_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
|
|
OPENROUTER_DEFAULT_MODEL: z.string().nullable().optional(),
|
|
OLLAMA_BASE_URL: z.string().nullable().optional(),
|
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().nullable().optional(),
|
|
VAPID_PRIVATE_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
|
|
SIGNUPS_DISABLED: z.string().nullable().optional(),
|
|
DEFAULT_TEXT_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for recipe/chat text generation when a user has no personal model preference and no BYOK key."),
|
|
DEFAULT_TEXT_MODEL: z.string().nullable().optional(),
|
|
DEFAULT_VISION_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for photo/vision use cases (pantry scan, photo import)."),
|
|
DEFAULT_VISION_MODEL: z.string().nullable().optional(),
|
|
DEFAULT_MEAL_PLAN_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for AI meal-plan generation."),
|
|
DEFAULT_MEAL_PLAN_MODEL: z.string().nullable().optional(),
|
|
}).describe("Unknown keys are silently ignored. Setting a key to null or \"\" deletes it (falls back to env var)."));
|
|
|
|
const TestEmailBodyRef = registry.register("TestEmailBody", z.object({ to: z.string().min(1) }));
|
|
|
|
const UpdateTierDefinitionBodyRef = registry.register("UpdateTierDefinitionBody", z.object({
|
|
maxRecipes: z.number().int().optional(), aiCallsPerMonth: z.number().int().optional(),
|
|
storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
|
|
}).describe("Each field must be a non-negative integer, or -1 for unlimited."));
|
|
const TierDefinitionRef = registry.register("TierDefinition", z.object({
|
|
tier: z.enum(["free", "pro"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
|
|
storageMb: z.number().int(), maxPublicRecipes: z.number().int(),
|
|
}));
|
|
|
|
const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({
|
|
email: z.string(), name: z.string(), role: z.enum(["user", "moderator", "admin"]).default("user"),
|
|
tier: z.enum(["free", "pro"]).default("free"),
|
|
}));
|
|
const AdminCreatedUserRef = registry.register("AdminCreatedUser", z.object({
|
|
user: z.object({ id: z.string(), email: z.string(), name: z.string(), role: z.string(), tier: z.string() }),
|
|
}));
|
|
|
|
const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
|
|
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro"]).optional(),
|
|
}));
|
|
const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
|
|
user: z.object({ id: z.string(), role: z.string(), tier: z.string() }),
|
|
}));
|
|
|
|
const AdminUserUsageRef = registry.register("AdminUserUsage", z.object({
|
|
id: z.string().optional(), userId: z.string(), month: z.string(),
|
|
aiCallsUsed: z.number().int(), recipeCount: z.number().int(), storageUsedMb: z.number().int(),
|
|
}));
|
|
|
|
const tierParam = z.object({ tier: z.enum(["free", "pro"]) });
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/admin/invites", summary: "List invites", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Invites", content: { "application/json": { schema: z.object({ invites: z.array(InviteRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "post", path: "/api/v1/admin/invites", summary: "Create an invite", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateInviteRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: z.object({ invite: InviteRef }) } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "delete", path: "/api/v1/admin/invites/{id}", summary: "Revoke an invite", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Revoked", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Invite not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
registry.registerPath({ method: "put", path: "/api/v1/admin/settings", summary: "Update site settings (AI provider keys, VAPID keys, signups toggle)", description: "Admin only. Secret-flagged keys are encrypted at rest and never echoed back by any endpoint; this route only accepts new values, it does not return current ones.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateSiteSettingsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
registry.registerPath({ method: "post", path: "/api/v1/admin/test-email", summary: "Send a test verification-style email to a given address", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: TestEmailBodyRef } }, required: true } }, responses: { 200: { description: "Sent", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Missing 'to'", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "BETTER_AUTH_URL not configured, or send failed", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
registry.registerPath({ method: "patch", path: "/api/v1/admin/tiers/{tier}", summary: "Update numeric limits for a tier definition", description: "Admin only.", security: adminSecurity, request: { params: tierParam, body: { content: { "application/json": { schema: UpdateTierDefinitionBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ tierDefinition: TierDefinitionRef }) } } }, 400: { description: "Invalid tier or field value", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Tier not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
registry.registerPath({ method: "post", path: "/api/v1/admin/users", summary: "Create a user directly (bypasses open/closed signup state via an internal one-time invite)", description: "Admin only. The new user is created email-verified with a random unusable password, then sent a password-reset email so they can set their own.", security: adminSecurity, request: { body: { content: { "application/json": { schema: AdminCreateUserBodyRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: AdminCreatedUserRef } } }, 400: { description: "Missing fields, invalid role/tier, or Better Auth signup error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "A user with this email already exists", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "User creation failed", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role and/or tier", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's usage counters for the current month", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
|
|
|
const generator = new OpenApiGeneratorV31(registry.definitions);
|
|
cachedSpec = generator.generateDocument({
|
|
openapi: "3.1.0",
|
|
info: { title: "Epicure API", version: "1.0.0", description: "Auth: session cookie or Bearer API key (`ek_...`)." },
|
|
servers: [{ url: process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000" }],
|
|
});
|
|
|
|
return cachedSpec;
|
|
}
|