From fb9f97e2c1586c7946c1332c342488d7b0da231f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 03:30:46 +0000 Subject: [PATCH] feat(api): POST /api/packages/upload (REST counterpart of MCP upload_packages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /account/tokens page advertises a curl/fetch snippet against POST /api/packages/upload, but the route did not exist — the SPA caught the unmatched path and returned HTML, leaving curl/CI users without a working write path. Adds the route with Bearer auth (sas_… PAT or OAuth access token) that reuses processBulkUpload, so REST and MCP share the same pipeline, queue, and private-draft semantics. `publish:true` is accepted in the body for forward compatibility but ignored — marketplace listing still requires the /account/packages submit flow + admin review. Also extracts verifyBearer/extractBearer into src/lib/auth/bearer.server.ts so /api/mcp and /api/packages/upload validate tokens identically. https://claude.ai/code/session_019gMoupKKTVydpNwiiACQRd --- src/lib/auth/bearer.server.ts | 43 ++++++++++++ src/routeTree.gen.ts | 21 ++++++ src/routes/api/mcp.ts | 26 +------ src/routes/api/packages.upload.ts | 112 ++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 src/lib/auth/bearer.server.ts create mode 100644 src/routes/api/packages.upload.ts diff --git a/src/lib/auth/bearer.server.ts b/src/lib/auth/bearer.server.ts new file mode 100644 index 00000000..47bce006 --- /dev/null +++ b/src/lib/auth/bearer.server.ts @@ -0,0 +1,43 @@ +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +import { sha256 } from "@/lib/oauth/mcp-oauth.server"; +import { hashToken } from "@/lib/account/tokens.server"; + +const supabaseAdmin = _supabaseAdmin as any; + +export type BearerAuth = { user_id: string; source: "oauth" | "pat" }; + +/** OAuth access token first, then legacy personal MCP token (`sas_...`). */ +export async function verifyBearer(token: string): Promise { + const { data: oauth } = await supabaseAdmin.rpc("mcp_oauth_verify_access", { + _token_hash: sha256(token), + } as never); + const oauthRow = oauth as { user_id?: string } | null; + if (oauthRow?.user_id) return { user_id: oauthRow.user_id, source: "oauth" }; + + if ( + token.startsWith("sas_") && + !token.startsWith("sas_at_") && + !token.startsWith("sas_rt_") && + !token.startsWith("sas_code_") + ) { + const { data } = await supabaseAdmin + .from("mcp_tokens") + .select("user_id,id") + .eq("token_hash", hashToken(token)) + .maybeSingle(); + if (data?.user_id) { + await supabaseAdmin + .from("mcp_tokens") + .update({ last_used_at: new Date().toISOString() }) + .eq("id", data.id); + return { user_id: data.user_id, source: "pat" }; + } + } + return null; +} + +/** Extract a Bearer token from an `Authorization` header (empty string if absent). */ +export function extractBearer(req: Request): string { + const h = req.headers.get("authorization") ?? ""; + return h.startsWith("Bearer ") ? h.slice(7).trim() : ""; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 27220636..b2d1dab8 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -89,6 +89,7 @@ import { Route as ForgeReportSlugRouteImport } from './routes/forge.report.$slug import { Route as ApiPublicTelemetryRouteImport } from './routes/api/public/telemetry' import { Route as ApiPublicSearchRouteImport } from './routes/api/public/search' import { Route as ApiPublicPackagesRouteImport } from './routes/api/public/packages' +import { Route as ApiPackagesUploadRouteImport } from './routes/api/packages.upload' import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp/health' import { Route as ApiJobsDrainUploadQueueRouteImport } from './routes/api/jobs/drain-upload-queue' import { Route as ApiAdminAiGatewayProbeRouteImport } from './routes/api/admin/ai-gateway-probe' @@ -515,6 +516,11 @@ const ApiPublicPackagesRoute = ApiPublicPackagesRouteImport.update({ path: '/api/public/packages', getParentRoute: () => rootRouteImport, } as any) +const ApiPackagesUploadRoute = ApiPackagesUploadRouteImport.update({ + id: '/api/packages/upload', + path: '/api/packages/upload', + getParentRoute: () => rootRouteImport, +} as any) const ApiMcpHealthRoute = ApiMcpHealthRouteImport.update({ id: '/health', path: '/health', @@ -720,6 +726,7 @@ export interface FileRoutesByFullPath { '/api/admin/ai-gateway-probe': typeof ApiAdminAiGatewayProbeRoute '/api/jobs/drain-upload-queue': typeof ApiJobsDrainUploadQueueRoute '/api/mcp/health': typeof ApiMcpHealthRoute + '/api/packages/upload': typeof ApiPackagesUploadRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute '/api/public/telemetry': typeof ApiPublicTelemetryRoute @@ -824,6 +831,7 @@ export interface FileRoutesByTo { '/api/admin/ai-gateway-probe': typeof ApiAdminAiGatewayProbeRoute '/api/jobs/drain-upload-queue': typeof ApiJobsDrainUploadQueueRoute '/api/mcp/health': typeof ApiMcpHealthRoute + '/api/packages/upload': typeof ApiPackagesUploadRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute '/api/public/telemetry': typeof ApiPublicTelemetryRoute @@ -930,6 +938,7 @@ export interface FileRoutesById { '/api/admin/ai-gateway-probe': typeof ApiAdminAiGatewayProbeRoute '/api/jobs/drain-upload-queue': typeof ApiJobsDrainUploadQueueRoute '/api/mcp/health': typeof ApiMcpHealthRoute + '/api/packages/upload': typeof ApiPackagesUploadRoute '/api/public/packages': typeof ApiPublicPackagesRouteWithChildren '/api/public/search': typeof ApiPublicSearchRoute '/api/public/telemetry': typeof ApiPublicTelemetryRoute @@ -1037,6 +1046,7 @@ export interface FileRouteTypes { | '/api/admin/ai-gateway-probe' | '/api/jobs/drain-upload-queue' | '/api/mcp/health' + | '/api/packages/upload' | '/api/public/packages' | '/api/public/search' | '/api/public/telemetry' @@ -1141,6 +1151,7 @@ export interface FileRouteTypes { | '/api/admin/ai-gateway-probe' | '/api/jobs/drain-upload-queue' | '/api/mcp/health' + | '/api/packages/upload' | '/api/public/packages' | '/api/public/search' | '/api/public/telemetry' @@ -1246,6 +1257,7 @@ export interface FileRouteTypes { | '/api/admin/ai-gateway-probe' | '/api/jobs/drain-upload-queue' | '/api/mcp/health' + | '/api/packages/upload' | '/api/public/packages' | '/api/public/search' | '/api/public/telemetry' @@ -1333,6 +1345,7 @@ export interface RootRouteChildren { PacksIndexRoute: typeof PacksIndexRoute ApiAdminAiGatewayProbeRoute: typeof ApiAdminAiGatewayProbeRoute ApiJobsDrainUploadQueueRoute: typeof ApiJobsDrainUploadQueueRoute + ApiPackagesUploadRoute: typeof ApiPackagesUploadRoute ApiPublicPackagesRoute: typeof ApiPublicPackagesRouteWithChildren ApiPublicSearchRoute: typeof ApiPublicSearchRoute ApiPublicTelemetryRoute: typeof ApiPublicTelemetryRoute @@ -1916,6 +1929,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicPackagesRouteImport parentRoute: typeof rootRouteImport } + '/api/packages/upload': { + id: '/api/packages/upload' + path: '/api/packages/upload' + fullPath: '/api/packages/upload' + preLoaderRoute: typeof ApiPackagesUploadRouteImport + parentRoute: typeof rootRouteImport + } '/api/mcp/health': { id: '/api/mcp/health' path: '/health' @@ -2298,6 +2318,7 @@ const rootRouteChildren: RootRouteChildren = { PacksIndexRoute: PacksIndexRoute, ApiAdminAiGatewayProbeRoute: ApiAdminAiGatewayProbeRoute, ApiJobsDrainUploadQueueRoute: ApiJobsDrainUploadQueueRoute, + ApiPackagesUploadRoute: ApiPackagesUploadRoute, ApiPublicPackagesRoute: ApiPublicPackagesRouteWithChildren, ApiPublicSearchRoute: ApiPublicSearchRoute, ApiPublicTelemetryRoute: ApiPublicTelemetryRoute, diff --git a/src/routes/api/mcp.ts b/src/routes/api/mcp.ts index 08f4c73f..646cee67 100644 --- a/src/routes/api/mcp.ts +++ b/src/routes/api/mcp.ts @@ -16,7 +16,7 @@ import { import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; const supabaseAdmin = _supabaseAdmin as any; import { ORIGIN, sha256, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; -import { hashToken } from "@/lib/account/tokens.server"; +import { verifyBearer } from "@/lib/auth/bearer.server"; const mcp = createMcpServer({ name: "superagentskill", @@ -107,30 +107,6 @@ function withRateLimitHeaders( return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); } -/** Try OAuth tokens first, then fall back to legacy MCP personal tokens. */ -async function verifyBearer(token: string): Promise<{ user_id: string; source: "oauth" | "pat" } | null> { - // OAuth access token - const { data: oauth } = await supabaseAdmin.rpc("mcp_oauth_verify_access", { - _token_hash: sha256(token), - } as never); - const oauthRow = oauth as { user_id?: string } | null; - if (oauthRow?.user_id) return { user_id: oauthRow.user_id, source: "oauth" }; - - // Legacy personal access token (sas_...) - if (token.startsWith("sas_") && !token.startsWith("sas_at_") && !token.startsWith("sas_rt_") && !token.startsWith("sas_code_")) { - const { data } = await supabaseAdmin - .from("mcp_tokens") - .select("user_id,id") - .eq("token_hash", hashToken(token)) - .maybeSingle(); - if (data?.user_id) { - await supabaseAdmin.from("mcp_tokens").update({ last_used_at: new Date().toISOString() }).eq("id", data.id); - return { user_id: data.user_id, source: "pat" }; - } - } - return null; -} - function unauthorized(reason: string, rpcId: string | number | null = null) { // Return BOTH a JSON-RPC error (so MCP clients that surface error.data.hint // in chat can render the recovery action inline) and the canonical diff --git a/src/routes/api/packages.upload.ts b/src/routes/api/packages.upload.ts new file mode 100644 index 00000000..ef8db77f --- /dev/null +++ b/src/routes/api/packages.upload.ts @@ -0,0 +1,112 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { processBulkUpload } from "@/lib/uploads/uploads.server"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +import { verifyBearer, extractBearer } from "@/lib/auth/bearer.server"; + +const supabaseAdmin = _supabaseAdmin as any; + +// REST counterpart of the MCP `upload_packages` tool. Advertised on the +// /account/tokens page snippets. Always inserts as PRIVATE drafts — +// `publish:true` is accepted in the body for forward compatibility but +// ignored; publishing the marketplace requires an explicit action in +// /account/packages followed by an admin review. + +const BodySchema = z.object({ + files: z + .array( + z.object({ + name: z.string().min(1).max(200), + content: z.string().min(1).max(200_000), + type: z.enum(["skill", "playbook", "soul", "guardrail"]).optional(), + }) + ) + .min(1) + .max(10), + publish: z.boolean().optional(), +}); + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Max-Age": "86400", +}; + +function json(body: unknown, init: { status?: number; headers?: Record } = {}) { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...CORS, + ...(init.headers ?? {}), + }, + }); +} + +async function handle(request: Request): Promise { + const token = extractBearer(request); + if (!token) { + return json( + { error: "unauthorized", hint: "Send Authorization: Bearer ." }, + { status: 401, headers: { "WWW-Authenticate": "Bearer" } } + ); + } + const auth = await verifyBearer(token); + if (!auth) { + return json( + { error: "unauthorized", hint: "Token rejected. Mint a fresh one at /account/tokens." }, + { status: 401, headers: { "WWW-Authenticate": "Bearer" } } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ error: "bad_request", hint: "Body must be JSON." }, { status: 400 }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return json( + { error: "bad_request", issues: parsed.error.issues }, + { status: 400 } + ); + } + + const { files, publish } = parsed.data; + try { + const { results, queued } = await processBulkUpload(supabaseAdmin, auth.user_id, files); + const ok = results.filter((r) => r.ok).length; + const failed = results.length - ok; + return json({ + uploaded: ok, + failed, + queued_count: queued.length, + visibility: "private_draft", + publish_ignored: publish === true ? "Public listing requires admin review — submit from /account/packages." : undefined, + next_step: + queued.length > 0 + ? `Processed ${ok} inline; ${queued.length} more queued. Background worker drains within ~1 minute — see /account/packages.` + : ok > 0 + ? "Open /account/packages to submit a draft for admin review." + : "All files failed to normalise. See `results[].error` for the cause.", + results, + queued, + }); + } catch (e: any) { + console.error("[api/packages/upload] fatal:", e); + return json({ error: "internal", message: e?.message ?? String(e) }, { status: 500 }); + } +} + +export const Route = createFileRoute("/api/packages/upload")({ + server: { + handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS }), + POST: async ({ request }) => handle(request), + }, + }, +});