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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ APP_BASE_URL=http://localhost:3000
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=

# PymtHouse (usage + API keys + signer sessions + plans/subscribe)
# Production:
PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc
# Local pymthouse (`npm run dev` HTTPS): https://localhost:3001/api/v1/oidc
PYMTHOUSE_PUBLIC_CLIENT_ID=
PYMTHOUSE_M2M_CLIENT_ID=
PYMTHOUSE_M2M_CLIENT_SECRET=
# Set to 1 for local http issuer only (not needed for https://localhost with mkcert)
PYMTHOUSE_ALLOW_INSECURE_HTTP=
190 changes: 190 additions & 0 deletions app/api/pymthouse/keys/exchange/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { PmtHouseError } from "@pymthouse/builder-sdk";
import {
normalizeDeviceExchangeResponse,
parseApiKeyExchangeRequestBody,
} from "@pymthouse/builder-sdk/signer/server";
import {
pymthouseAppsOrigin,
readPymthouseM2mConfig,
readPublicClientId,
} from "@/lib/console/pymthouse-http";

const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";

type ExchangeConfig = {
issuerUrl: string;
publicClientId: string;
m2mClientId: string;
m2mClientSecret: string;
};

/** Thin BFF; canonical issuer route is POST …/apps/{clientId}/oidc/token (RFC 8693). */
function readApiKeyExchangeConfig(): ExchangeConfig | null {
const m2m = readPymthouseM2mConfig();
try {
const publicClientId = readPublicClientId();
const issuerUrl =
m2m?.issuerUrl ?? process.env.PYMTHOUSE_ISSUER_URL?.trim();
if (!issuerUrl) return null;
return {
issuerUrl,
publicClientId,
m2mClientId: m2m?.m2mClientId ?? "",
m2mClientSecret: m2m?.m2mClientSecret ?? "",
};
} catch {
return null;
}
}

function readStringField(
body: Record<string, unknown>,
key: string
): string | undefined {
const value = body[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

async function exchangeApiKeyViaOidcToken(input: {
config: ExchangeConfig;
apiKey: string;
scope?: string;
}): Promise<Response> {
const { config, apiKey, scope } = input;
const url = `${pymthouseAppsOrigin(config.issuerUrl)}/api/v1/apps/${encodeURIComponent(config.publicClientId)}/oidc/token`;

const form = new URLSearchParams({
grant_type: TOKEN_EXCHANGE_GRANT,
subject_token: apiKey,
subject_token_type: ACCESS_TOKEN_TYPE,
});
if (scope) {
form.set("scope", scope);
}

const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
if (config.m2mClientId && config.m2mClientSecret) {
const basic = Buffer.from(
[config.m2mClientId, config.m2mClientSecret].join(":")
).toString("base64");
headers.Authorization = `Basic ${basic}`;
}

const response = await fetch(url, {
method: "POST",
headers,
body: form.toString(),
cache: "no-store",
});

let parsed: Record<string, unknown>;
try {
parsed = (await response.json()) as Record<string, unknown>;
} catch {
throw new PmtHouseError("Token exchange returned invalid JSON", {
status: 502,
code: "invalid_exchange_response",
});
}

if (!response.ok) {
const description =
readStringField(parsed, "error_description") ||
readStringField(parsed, "error") ||
`Token exchange failed (${response.status})`;
throw new PmtHouseError(description, {
status: response.status,
code: readStringField(parsed, "error") ?? "api_key_exchange_failed",
});
}

const accessToken = readStringField(parsed, "access_token");
if (!accessToken) {
throw new PmtHouseError("Token exchange response missing access_token", {
status: 502,
code: "invalid_exchange_response",
});
}

// signer_url comes from the issuer exchange response (app signer routing).
const signerUrl = readStringField(parsed, "signer_url");

const expiresIn =
typeof parsed.expires_in === "number" && Number.isFinite(parsed.expires_in)
? parsed.expires_in
: 3600;

const body = normalizeDeviceExchangeResponse(
{
access_token: accessToken,
expires_in: expiresIn,
scope: readStringField(parsed, "scope") || scope || "sign:job",
balanceUsdMicros: readStringField(parsed, "balanceUsdMicros") ?? "0",
lifetimeGrantedUsdMicros:
readStringField(parsed, "lifetimeGrantedUsdMicros") ?? "0",
},
{ signer_url: signerUrl }
);

return Response.json(body, {
status: 200,
headers: { "Cache-Control": "no-store" },
});
}

function errorResponse(error: unknown): Response {
if (error instanceof PmtHouseError) {
return Response.json(
{
error: error.code ?? "api_key_exchange_failed",
error_description: error.message,
},
{ status: error.status ?? 500 }
);
}
const message =
error instanceof Error ? error.message : "API key exchange failed";
return Response.json(
{ error: "api_key_exchange_failed", error_description: message },
{ status: 500 }
);
}

export async function POST(request: Request) {
const config = readApiKeyExchangeConfig();
if (!config) {
return Response.json(
{
error: "server_misconfigured",
error_description:
"PYMTHOUSE_ISSUER_URL and PYMTHOUSE_PUBLIC_CLIENT_ID are required",
},
{ status: 503 }
);
}

try {
const parsed = await parseApiKeyExchangeRequestBody(request);
const effectiveClientId = parsed.clientId?.trim() || config.publicClientId;
if (effectiveClientId !== config.publicClientId) {
throw new PmtHouseError(
"clientId does not match configured public client",
{
status: 400,
code: "invalid_request",
}
);
}
return await exchangeApiKeyViaOidcToken({
config,
apiKey: parsed.apiKey,
scope: parsed.scope,
});
} catch (error) {
return errorResponse(error);
}
}
71 changes: 71 additions & 0 deletions app/api/pymthouse/keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import {
createDashboardApiKey,
listDashboardApiKeys,
revokeDashboardApiKey,
} from "@/lib/console/pymthouse-keys-bff";
import { requireConsoleSession } from "@/lib/console/session-user";
import {
PYMTHOUSE_NO_STORE_HEADERS,
pymthouseErrorResponse,
} from "@/app/api/pymthouse/route-helpers";

export const runtime = "nodejs";

export async function GET() {
try {
const session = await requireConsoleSession();
const keys = await listDashboardApiKeys(
session.externalUserId,
session.email
);
return NextResponse.json({ keys }, { headers: PYMTHOUSE_NO_STORE_HEADERS });
} catch (error) {
return pymthouseErrorResponse(error, "Failed to list API keys");
}
}

export async function POST(request: NextRequest) {
let body: { label?: string };
try {
body = (await request.json()) as typeof body;
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}

try {
const session = await requireConsoleSession();
const created = await createDashboardApiKey({
externalUserId: session.externalUserId,
email: session.email,
label: body.label,
});
return NextResponse.json(created, {
status: 201,
headers: PYMTHOUSE_NO_STORE_HEADERS,
});
} catch (error) {
return pymthouseErrorResponse(error, "Failed to create API key");
}
}

export async function DELETE(request: NextRequest) {
const keyId = request.nextUrl.searchParams.get("keyId")?.trim();
if (!keyId) {
return NextResponse.json({ error: "keyId is required" }, { status: 400 });
}

try {
const session = await requireConsoleSession();
await revokeDashboardApiKey({
externalUserId: session.externalUserId,
keyId,
});
return NextResponse.json(
{ success: true },
{ headers: PYMTHOUSE_NO_STORE_HEADERS }
);
} catch (error) {
return pymthouseErrorResponse(error, "Failed to revoke API key");
}
}
56 changes: 56 additions & 0 deletions app/api/pymthouse/route-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { PmtHouseError } from "@pymthouse/builder-sdk";
import { SessionRequiredError } from "@/lib/console/session-user";

export const PYMTHOUSE_NO_STORE_HEADERS = {
"Cache-Control": "no-store, max-age=0",
} as const;

export function pymthouseErrorResponse(
error: unknown,
fallback: string
): NextResponse {
if (error instanceof SessionRequiredError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS }
);
}
if (error instanceof PmtHouseError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status, headers: PYMTHOUSE_NO_STORE_HEADERS }
);
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : fallback },
{ status: 502, headers: PYMTHOUSE_NO_STORE_HEADERS }
);
}

/** Https-preferring public origin for Stripe Checkout return URLs. */
export function checkoutReturnOrigin(request: NextRequest): string {
const configuredOrigin = (
process.env.DASHBOARD_PUBLIC_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
process.env.APP_BASE_URL ||
""
)
.trim()
.replace(/\/$/, "");
let origin = configuredOrigin || request.nextUrl.origin;
try {
const parsed = new URL(origin);
if (
parsed.protocol === "http:" &&
parsed.hostname !== "localhost" &&
parsed.hostname !== "127.0.0.1"
) {
parsed.protocol = "https:";
}
origin = parsed.origin;
} catch {
origin = request.nextUrl.origin;
}
return origin;
}
Loading