-
Notifications
You must be signed in to change notification settings - Fork 10
Add API-first Sites lifecycle and MCP tools #913
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9d305b2
a255eb2
61247e5
be30dc2
38f4d7c
21c30eb
bdf09c2
e106c2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { actionSchema } from "@/lib/sites/schema"; | ||
| import { siteOperationHandler } from "@/lib/sites/siteOperationHandler"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| export const maxDuration = 300; | ||
| type Context = { params: Promise<{ id: string }> }; | ||
| /** | ||
| * Read the authenticated site's current version. | ||
| * | ||
| * @param request - Request or route context. | ||
| * @param context - Request or route context. | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function GET(request: NextRequest, context: Context) { | ||
| return siteOperationHandler(request, "get", await context.params); | ||
| } | ||
| /** | ||
| * Generate, publish or unpublish using an expected revision. | ||
| * | ||
| * @param request - Request or route context. | ||
| * @param context - Request or route context. | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function PATCH(request: NextRequest, context: Context) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: API Design Consistency and Maintainability The Prompt for AI agents |
||
| const parsed = actionSchema.safeParse(await request.json().catch(() => null)); | ||
| if (!parsed.success) | ||
| return NextResponse.json( | ||
| { error: "Invalid site action" }, | ||
| { status: 400, headers: getCorsHeaders() }, | ||
| ); | ||
| const { action, ...input } = parsed.data; | ||
| return siteOperationHandler(request, action, { ...input, ...(await context.params) }); | ||
| } | ||
| /** | ||
| * Browser preflight. | ||
| * | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function OPTIONS() { | ||
| return new Response(null, { status: 204, headers: getCorsHeaders() }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,22 @@ | ||||||||||||||||||||||
| import { NextRequest } from "next/server"; | ||||||||||||||||||||||
| import { siteOperationHandler } from "@/lib/sites/siteOperationHandler"; | ||||||||||||||||||||||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Read fan signups only after verifying workspace access. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @param request - Request or route context. | ||||||||||||||||||||||
| * @param root0 - Request or route context. | ||||||||||||||||||||||
| * @param root0.params - Request or route context. | ||||||||||||||||||||||
| * @returns HTTP response. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
|
Comment on lines
+5
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Flag AI Slop and Fabricated Changes The GET handler's JSDoc documents a nonexistent Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { | ||||||||||||||||||||||
| return siteOperationHandler(request, "signups", await params); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Browser preflight. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @returns HTTP response. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export async function OPTIONS() { | ||||||||||||||||||||||
| return new Response(null, { status: 204, headers: getCorsHeaders() }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,36 @@ | ||||||||||||||||||||||
| import { NextRequest, NextResponse } from "next/server"; | ||||||||||||||||||||||
| import { validateAuthContext } from "@/lib/auth/validateAuthContext"; | ||||||||||||||||||||||
| import { processSiteAsset } from "@/lib/sites/processSiteAsset"; | ||||||||||||||||||||||
| import { siteResponseError } from "@/lib/sites/siteResponseError"; | ||||||||||||||||||||||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Upload workspace-owned artwork or audio. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @param request - Request or route context. | ||||||||||||||||||||||
| * @returns HTTP response. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export async function POST(request: NextRequest) { | ||||||||||||||||||||||
| const auth = await validateAuthContext(request); | ||||||||||||||||||||||
| if (auth instanceof NextResponse) return auth; | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const data = await request.formData(); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a caller sends JSON or malformed multipart data, Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||
| await processSiteAsset( | ||||||||||||||||||||||
| auth.accountId, | ||||||||||||||||||||||
| request.nextUrl.searchParams.get("organizationId"), | ||||||||||||||||||||||
| data.get("file"), | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| { headers: getCorsHeaders() }, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| return siteResponseError(error); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Sharp or Supabase fails unexpectedly, this catch returns a generic 503 without logging (Based on your team's feedback about logging unexpected API errors.) Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Browser preflight. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * @returns HTTP response. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export async function OPTIONS() { | ||||||||||||||||||||||
| return new Response(null, { status: 204, headers: getCorsHeaders() }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { processPublicSite } from "@/lib/sites/processPublicSite"; | ||
| import { siteResponseError } from "@/lib/sites/siteResponseError"; | ||
| export const dynamic = "force-dynamic"; | ||
| /** | ||
| * Read a published snapshot without exposing the private draft. | ||
| * | ||
| * @param _request - Request or route context. | ||
| * @param root0 - Request or route context. | ||
| * @param root0.params - Request or route context. | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { | ||
| try { | ||
| return NextResponse.json(await processPublicSite((await params).id), { | ||
| headers: { "Cache-Control": "no-store" }, | ||
| }); | ||
| } catch (error) { | ||
| return siteResponseError(error); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,21 @@ | ||||||||
| import { NextResponse } from "next/server"; | ||||||||
| import { processPublicSite } from "@/lib/sites/processPublicSite"; | ||||||||
| import { siteResponseError } from "@/lib/sites/siteResponseError"; | ||||||||
| /** | ||||||||
| * Record explicit fan email consent on a currently published site. | ||||||||
| * | ||||||||
| * @param request - Request or route context. | ||||||||
| * @param root0 - Request or route context. | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Flag AI Slop and Fabricated Changes The added JSDoc documents nonexistent Prompt for AI agents |
||||||||
| * @param root0.params - Request or route context. | ||||||||
| * @returns HTTP response. | ||||||||
| */ | ||||||||
| export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: API Design Consistency and Maintainability Anonymous callers can repeatedly trigger signup inserts because this public POST route has no rate-limit guard. Add API-layer request validation and rate limiting before invoking Prompt for AI agents |
||||||||
| try { | ||||||||
| return NextResponse.json( | ||||||||
| await processPublicSite((await params).id, await request.json().catch(() => null)), | ||||||||
| { headers: { "Cache-Control": "no-store" } }, | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the public page or fan signup form runs on a different origin than this API (chat/app.recoupable.dev vs api.recoupable.dev), browsers cannot read these success responses: the GET and POST success JSONs set only Prompt for AI agents |
||||||||
| ); | ||||||||
| } catch (error) { | ||||||||
| return siteResponseError(error); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When signup persistence fails, this catch returns a sanitized 503 but silently discards the exception, leaving no server-side diagnostic for the failure. Log (Based on your team's feedback about server-side logging for sanitized 500 responses.) Prompt for AI agents
Suggested change
|
||||||||
| } | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { NextRequest } from "next/server"; | ||
| import { siteOperationHandler } from "@/lib/sites/siteOperationHandler"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| export const maxDuration = 300; | ||
| /** | ||
| * List sites in the authenticated workspace. | ||
| * | ||
| * @param request - Request or route context. | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function GET(request: NextRequest) { | ||
| return siteOperationHandler(request, "list", Object.fromEntries(request.nextUrl.searchParams)); | ||
| } | ||
| /** | ||
| * Create a private, durable draft. | ||
| * | ||
| * @param request - Request or route context. | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function POST(request: NextRequest) { | ||
| return siteOperationHandler(request, "create", await request.json().catch(() => null)); | ||
| } | ||
| /** | ||
| * Browser preflight. | ||
| * | ||
| * @returns HTTP response. | ||
| */ | ||
| export async function OPTIONS() { | ||
| return new Response(null, { status: 204, headers: getCorsHeaders() }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { produceAssets } from "@/lib/sites/production/produceAssets"; | ||
| import { FatalError } from "workflow"; | ||
| export async function assetsStep(...args: Parameters<typeof produceAssets>) { | ||
| "use step"; | ||
| try { | ||
| return await produceAssets(...args); | ||
| } catch (error) { | ||
| console.error( | ||
| "[sites:assetsStep]", | ||
| error instanceof Error | ||
| ? { name: error.name, message: error.message.slice(0, 1200) } | ||
| : "Unknown failure", | ||
| ); | ||
| throw new FatalError( | ||
| "Site production assetsStep failed. No automatic provider retry was attempted.", | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { buildExperience } from "@/lib/sites/production/buildExperience"; | ||
| import { FatalError } from "workflow"; | ||
| export async function buildStep(...args: Parameters<typeof buildExperience>) { | ||
| "use step"; | ||
| try { | ||
| return await buildExperience(...args); | ||
| } catch (error) { | ||
| console.error( | ||
| "[sites:buildStep]", | ||
| error instanceof Error | ||
| ? { name: error.name, message: error.message.slice(0, 1200) } | ||
| : "Unknown failure", | ||
| ); | ||
| throw new FatalError( | ||
| "Site production buildStep failed. No automatic provider retry was attempted.", | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { collectReleaseContext } from "@/lib/sites/production/collectReleaseContext"; | ||
| import { FatalError } from "workflow"; | ||
| export async function collectContextStep(...args: Parameters<typeof collectReleaseContext>) { | ||
| "use step"; | ||
| try { | ||
| return await collectReleaseContext(...args); | ||
| } catch (error) { | ||
| console.error( | ||
| "[sites:collectContextStep]", | ||
| error instanceof Error | ||
| ? { name: error.name, message: error.message.slice(0, 1200) } | ||
| : "Unknown failure", | ||
| ); | ||
| throw new FatalError( | ||
| "Site production collectContextStep failed. No automatic provider retry was attempted.", | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { directExperience } from "@/lib/sites/production/directExperience"; | ||
| import { FatalError } from "workflow"; | ||
| export async function directionStep(...args: Parameters<typeof directExperience>) { | ||
| "use step"; | ||
| try { | ||
| return await directExperience(...args); | ||
| } catch (error) { | ||
| console.error( | ||
| "[sites:directionStep]", | ||
| error instanceof Error | ||
| ? { name: error.name, message: error.message.slice(0, 1200) } | ||
| : "Unknown failure", | ||
| ); | ||
| throw new FatalError( | ||
| "Site production directionStep failed. No automatic provider retry was attempted.", | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { reviewExperience } from "@/lib/sites/production/reviewExperience"; | ||
| import { FatalError } from "workflow"; | ||
| export async function reviewStep(...args: Parameters<typeof reviewExperience>) { | ||
| "use step"; | ||
| try { | ||
| return await reviewExperience(...args); | ||
| } catch (error) { | ||
| console.error( | ||
| "[sites:reviewStep]", | ||
| error instanceof Error | ||
| ? { name: error.name, message: error.message.slice(0, 1200) } | ||
| : "Unknown failure", | ||
| ); | ||
| throw new FatalError( | ||
| "Site production reviewStep failed. No automatic provider retry was attempted.", | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { reviseProduction } from "@/lib/sites/production/reviseProduction"; | ||
| import { FatalError } from "workflow"; | ||
| export async function reviseStep(...args: Parameters<typeof reviseProduction>) { | ||
| "use step"; | ||
| try { | ||
| return await reviseProduction(...args); | ||
| } catch (error) { | ||
| console.error( | ||
| "[sites:reviseStep]", | ||
| error instanceof Error | ||
| ? { name: error.name, message: error.message.slice(0, 1200) } | ||
| : "Unknown failure", | ||
| ); | ||
| throw new FatalError("Site revision failed. No automatic provider retry was attempted."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { updateSite } from "@/lib/supabase/sites/updateSite"; | ||
| import { authorizeSiteWorkspace } from "@/lib/sites/authorizeSiteWorkspace"; | ||
| import type { Site, SiteSnapshot } from "@/lib/sites/schema"; | ||
| import { FatalError } from "workflow"; | ||
| export async function saveSiteStep(site: Site, draft: SiteSnapshot, accountId: string) { | ||
| "use step"; | ||
| await authorizeSiteWorkspace(accountId, site.owner_id); | ||
| const updated = await updateSite(site.id, site.owner_id, site.revision, { draft }); | ||
| if (!updated) | ||
| throw new FatalError("Site changed during generation. The newer draft was preserved."); | ||
| return { site: updated }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { reviseStep } from "./reviseStep"; | ||
| import type { Site } from "@/lib/sites/schema"; | ||
| import { collectContextStep } from "./collectContextStep"; | ||
| import { directionStep } from "./directionStep"; | ||
| import { assetsStep } from "./assetsStep"; | ||
| import { buildStep } from "./buildStep"; | ||
| import { reviewStep } from "./reviewStep"; | ||
| import { saveSiteStep } from "./saveSiteStep"; | ||
| /** Completed stages are durable; a browser disconnect does not discard production. */ | ||
| export async function siteProductionWorkflow(site: Site, instruction: string, accountId: string) { | ||
| "use workflow"; | ||
| try { | ||
| const context = await collectContextStep(site, accountId); | ||
| let direction = await directionStep(site, instruction, context, accountId); | ||
| let assets = await assetsStep(site, direction, accountId); | ||
| let snapshot = await buildStep( | ||
| site, | ||
| instruction, | ||
| { release: context, direction }, | ||
| assets, | ||
| accountId, | ||
| ); | ||
| const reviews = [await reviewStep(snapshot, direction, accountId, site.id)]; | ||
| if (reviews[0].verdict === "revise") { | ||
| ({ snapshot, direction, assets } = await reviseStep( | ||
| site, | ||
| instruction, | ||
| context, | ||
| direction, | ||
| assets, | ||
| snapshot, | ||
| reviews[0], | ||
| accountId, | ||
| )); | ||
| reviews.push(await reviewStep(snapshot, direction, accountId, site.id)); | ||
| } | ||
| return await saveSiteStep( | ||
| site, | ||
| { | ||
| ...snapshot, | ||
| production: { | ||
| version: 1, | ||
| context, | ||
| direction, | ||
| reviews, | ||
| status: reviews[reviews.length - 1].verdict === "pass" ? "reviewed" : "needs-review", | ||
| }, | ||
| }, | ||
| accountId, | ||
| ); | ||
| } catch { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents |
||
| return { | ||
| error: "Production stopped before a draft could be saved. Your existing draft is unchanged.", | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Custom agent: API Design Consistency and Maintainability
This handler uses
PATCHfor thegenerate,publish, andunpublishmutations, but the API design rule requiresPOSTfor actions or mutations. Expose this action endpoint throughPOSTinstead.Prompt for AI agents