Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/lib/auth/bearer.server.ts
Original file line number Diff line number Diff line change
@@ -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<BearerAuth | null> {
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() : "";
}
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -2298,6 +2318,7 @@ const rootRouteChildren: RootRouteChildren = {
PacksIndexRoute: PacksIndexRoute,
ApiAdminAiGatewayProbeRoute: ApiAdminAiGatewayProbeRoute,
ApiJobsDrainUploadQueueRoute: ApiJobsDrainUploadQueueRoute,
ApiPackagesUploadRoute: ApiPackagesUploadRoute,
ApiPublicPackagesRoute: ApiPublicPackagesRouteWithChildren,
ApiPublicSearchRoute: ApiPublicSearchRoute,
ApiPublicTelemetryRoute: ApiPublicTelemetryRoute,
Expand Down
26 changes: 1 addition & 25 deletions src/routes/api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions src/routes/api/packages.upload.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } = {}) {
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<Response> {
const token = extractBearer(request);
if (!token) {
return json(
{ error: "unauthorized", hint: "Send Authorization: Bearer <sas_… token from /account/tokens>." },
{ 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),
},
},
});
Loading