From 60bdb2280fcffedb6aa23c2528f61c0ab8e2a93d Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 18 Apr 2026 18:56:40 -0400 Subject: [PATCH 1/5] feat: integrate PymtHouse authentication flow and enhance session management Added support for PymtHouse integration, including new environment variables for issuer URL and client IDs. Implemented device login approval and completion routes, along with user session management. Updated the login page to handle device flow and improved the AuthContext for better state management. Enhanced the API for token management and usage tracking, ensuring a seamless user experience across the application. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 14 + app/(studio)/studio/header-qa/page.tsx | 2 +- .../studio/device-approved/page.tsx | 45 +++ app/(studio-auth)/studio/login/page.tsx | 8 +- app/api/auth/device/complete/route.ts | 66 ++++ app/api/auth/initiate-login/route.ts | 45 +++ app/api/auth/login/route.ts | 107 ++++++ app/api/auth/logout/route.ts | 10 + app/api/auth/me/route.ts | 27 ++ app/api/tokens/[id]/route.ts | 38 ++ app/api/tokens/route.ts | 101 +++++ app/api/usage/route.ts | 174 +++++++++ components/studio/AuthContext.tsx | 93 +++-- components/studio/LoginPage.tsx | 109 ++++-- components/studio/StudioHeader.tsx | 10 +- components/studio/settings/AccountTab.tsx | 2 +- components/studio/settings/ApiKeysTab.tsx | 306 +++++++++++---- components/studio/settings/UsageTab.tsx | 108 +++++- lib/pymthouse/README.md | 203 ++++++++++ lib/pymthouse/client.ts | 351 ++++++++++++++++++ lib/pymthouse/discovery.ts | 76 ++++ lib/pymthouse/errors.ts | 45 +++ lib/pymthouse/format.ts | 24 ++ lib/pymthouse/index.ts | 20 + lib/pymthouse/server.ts | 41 ++ lib/pymthouse/types.ts | 115 ++++++ lib/session.ts | 326 ++++++++++++++++ lib/studio-auth.ts | 63 ++++ package.json | 1 + pnpm-lock.yaml | 17 +- 30 files changed, 2378 insertions(+), 169 deletions(-) create mode 100644 app/(studio-auth)/studio/device-approved/page.tsx create mode 100644 app/api/auth/device/complete/route.ts create mode 100644 app/api/auth/initiate-login/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/tokens/[id]/route.ts create mode 100644 app/api/tokens/route.ts create mode 100644 app/api/usage/route.ts create mode 100644 lib/pymthouse/README.md create mode 100644 lib/pymthouse/client.ts create mode 100644 lib/pymthouse/discovery.ts create mode 100644 lib/pymthouse/errors.ts create mode 100644 lib/pymthouse/format.ts create mode 100644 lib/pymthouse/index.ts create mode 100644 lib/pymthouse/server.ts create mode 100644 lib/pymthouse/types.ts create mode 100644 lib/session.ts create mode 100644 lib/studio-auth.ts diff --git a/.env.example b/.env.example index d3ba41f..3f64356 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,17 @@ MAILCHIMP_TAG=v2 Website Signups # The Graph (optional — falls back to hardcoded values) THEGRAPH_API_KEY= + +# PymtHouse integration (required for Studio auth/device flow) +# Issuer must be the full OIDC issuer URL, e.g. http://localhost:3001/api/v1/oidc +PYMTHOUSE_ISSUER_URL= +# Public OIDC client id (app_...) +PYMTHOUSE_PUBLIC_CLIENT_ID= +# Confidential helper client id (m2m_...) +PYMTHOUSE_M2M_CLIENT_ID= +# Confidential helper secret (pmth_cs_...) +PYMTHOUSE_M2M_CLIENT_SECRET= + +# Website session signing secret +# Generate with: openssl rand -base64 32 +LP_SESSION_SECRET= diff --git a/app/(studio)/studio/header-qa/page.tsx b/app/(studio)/studio/header-qa/page.tsx index cda6f97..cfa92b5 100644 --- a/app/(studio)/studio/header-qa/page.tsx +++ b/app/(studio)/studio/header-qa/page.tsx @@ -34,7 +34,7 @@ export default function HeaderQaPage() { ))} diff --git a/components/studio/StudioHeader.tsx b/components/studio/StudioHeader.tsx index 5b6d457..46812d9 100644 --- a/components/studio/StudioHeader.tsx +++ b/components/studio/StudioHeader.tsx @@ -180,7 +180,10 @@ function AvatarMenu({ user, disconnect, compact = false, mobileMinimal = false } + {error &&

{error}

} + {/* Token list */}
- {keys.map((key) => ( -
- {/* Name + prefix + status */} -
-
-

{key.name}

- {key.status === "revoked" && ( - - Revoked - - )} + {isLoading ? ( +
Loading tokens...
+ ) : sortedKeys.length === 0 ? ( +
+ No tokens yet. Create your first `pmth_` token above. +
+ ) : ( + sortedKeys.map((key) => ( +
+ {/* Name + prefix + status */} +
+
+

{key.name}

+ {key.status === "revoked" && ( + + Revoked + + )} +
+

+ {key.prefix} +

+

+ Session token + · + + {formatRequests(key.calls7d)} + {" "} + requests this week +

-

- {key.prefix} - {"•".repeat(24)} -

-

- Free tier - · - - {formatRequests(key.calls7d)} - {" "} - requests this week -

-
- {/* Right rail: badge + actions */} -
- {key.isDefault && ( + {/* Right rail: badge + actions */} +
- Free tier + pmth_ - )} - handleCopy(key.prefix), - }, - { - label: "Revoke", - icon: Trash2, - destructive: true, - disabled: key.isDefault || key.status === "revoked", - onClick: () => handleRevoke(key.id), - }, - ]} - /> + void handleCopy(key.prefix), + }, + { + label: "Revoke", + icon: Trash2, + destructive: true, + disabled: key.status === "revoked", + onClick: () => void handleRevoke(key.id), + }, + ]} + /> +
-
- ))} + )) + )}
+ + setCreatedToken(null)} + maxWidth="max-w-[560px]" + > +
+

Token created

+

+ Copy this token now. You will not be able to view it again. +

+
+
+

+ {createdToken?.name} +

+
+ {createdToken?.token} +
+
+
+ + +
+
); } diff --git a/components/studio/settings/UsageTab.tsx b/components/studio/settings/UsageTab.tsx index db58ac8..ab2946f 100644 --- a/components/studio/settings/UsageTab.tsx +++ b/components/studio/settings/UsageTab.tsx @@ -128,24 +128,88 @@ export default function UsageTab() { const [period, setPeriod] = useState("30d"); const [signerFilter, setSignerFilter] = useState("all"); const [tokenFilter, setTokenFilter] = useState("all"); + const [usageLoading, setUsageLoading] = useState(false); + const [usageError, setUsageError] = useState(null); + const [usageData, setUsageData] = useState<{ + summary: typeof ACCOUNT_USAGE_SUMMARY; + bySigner: typeof ACCOUNT_USAGE_BY_SIGNER; + byToken: typeof ACCOUNT_USAGE_BY_TOKEN; + daily: typeof ACCOUNT_USAGE_DAILY; + } | null>(null); const [highlightedRequestId, setHighlightedRequestId] = useState< string | null >(null); const searchParams = useSearchParams(); const targetRequestId = searchParams.get("request"); + useEffect(() => { + let cancelled = false; + const fetchUsage = async () => { + setUsageLoading(true); + setUsageError(null); + try { + const response = await fetch(`/api/usage?period=${period}`, { + method: "GET", + cache: "no-store", + }); + const payload = (await response.json().catch(() => ({}))) as { + summary?: typeof ACCOUNT_USAGE_SUMMARY; + bySigner?: typeof ACCOUNT_USAGE_BY_SIGNER; + byToken?: typeof ACCOUNT_USAGE_BY_TOKEN; + daily?: typeof ACCOUNT_USAGE_DAILY; + error_description?: string; + }; + if (!response.ok) { + throw new Error(payload.error_description || "Failed to load usage data."); + } + if (!cancelled) { + setUsageData({ + summary: payload.summary ?? ACCOUNT_USAGE_SUMMARY, + bySigner: payload.bySigner ?? ACCOUNT_USAGE_BY_SIGNER, + byToken: payload.byToken ?? ACCOUNT_USAGE_BY_TOKEN, + daily: payload.daily ?? ACCOUNT_USAGE_DAILY, + }); + } + } catch (error) { + if (!cancelled) { + setUsageError( + error instanceof Error ? error.message : "Failed to load usage data.", + ); + setUsageData(null); + } + } finally { + if (!cancelled) { + setUsageLoading(false); + } + } + }; + + void fetchUsage(); + return () => { + cancelled = true; + }; + }, [period]); + + const summary = usageData?.summary ?? ACCOUNT_USAGE_SUMMARY; + const signerRows = usageData?.bySigner ?? ACCOUNT_USAGE_BY_SIGNER; + const tokenRows = usageData?.byToken ?? ACCOUNT_USAGE_BY_TOKEN; + const dailyRows = usageData?.daily ?? ACCOUNT_USAGE_DAILY; + const chartData = useMemo( - () => filterByPeriod(ACCOUNT_USAGE_DAILY, period), - [period], + () => filterByPeriod(dailyRows, period), + [dailyRows, period], ); const totalRequests = useMemo(() => { + if (usageData?.summary.requests != null) { + return usageData.summary.requests; + } return chartData.reduce( (sum, d) => sum + d.freeTier + d.paymthouse + d.livepeerCloud + d.ethWallet, 0, ); - }, [chartData]); + }, [chartData, usageData]); const visibleSigners: SignerKey[] = useMemo( () => (signerFilter === "all" ? SIGNER_KEYS : [signerFilter]), @@ -154,18 +218,18 @@ export default function UsageTab() { const filteredSignerRows = useMemo( () => - ACCOUNT_USAGE_BY_SIGNER.filter( + signerRows.filter( (row) => signerFilter === "all" || row.signer === signerFilter, ), - [signerFilter], + [signerFilter, signerRows], ); const filteredTokenRows = useMemo( () => - ACCOUNT_USAGE_BY_TOKEN.filter( + tokenRows.filter( (row) => tokenFilter === "all" || row.tokenId === tokenFilter, ), - [tokenFilter], + [tokenFilter, tokenRows], ); const periodCutoffMs = useMemo(() => { @@ -224,31 +288,31 @@ export default function UsageTab() { const headerStats: NetworkStat[] = useMemo(() => { const freePct = Math.round( - (ACCOUNT_USAGE_SUMMARY.freeTierUsed / - ACCOUNT_USAGE_SUMMARY.freeTierLimit) * + (summary.freeTierUsed / + summary.freeTierLimit) * 100, ); return [ { label: "Requests this period", - value: ACCOUNT_USAGE_SUMMARY.requests.toLocaleString(), + value: summary.requests.toLocaleString(), delta: "+12.4% vs last period", trend: "up", }, { label: "Spend this period", - value: ACCOUNT_USAGE_SUMMARY.spendDisplay, + value: summary.spendDisplay, delta: "+8.1% vs last period", trend: "up", }, { label: `Free tier (${freePct}% used)`, - value: `${ACCOUNT_USAGE_SUMMARY.freeTierUsed.toLocaleString()} / ${ACCOUNT_USAGE_SUMMARY.freeTierLimit.toLocaleString()}`, - delta: `Resets in ${ACCOUNT_USAGE_SUMMARY.freeTierResetIn}`, + value: `${summary.freeTierUsed.toLocaleString()} / ${summary.freeTierLimit.toLocaleString()}`, + delta: `Resets ${summary.freeTierResetIn}`, trend: "flat", }, ]; - }, []); + }, [summary]); const signerSelectOptions = useMemo<{ key: SignerFilter; label: string }[]>( () => [ @@ -261,16 +325,28 @@ export default function UsageTab() { const tokenSelectOptions = useMemo( () => [ { key: "all", label: "All tokens" }, - ...ACCOUNT_USAGE_BY_TOKEN.map((t) => ({ + ...tokenRows.map((t) => ({ key: t.tokenId, label: t.tokenName, })), ], - [], + [tokenRows], ); return (
+ {usageError && ( +
+ {usageError} +
+ )} + + {usageLoading && ( +
+ Syncing usage from pymthouse... +
+ )} + {/* KPI cards */}
{headerStats.map((stat) => ( diff --git a/lib/pymthouse/README.md b/lib/pymthouse/README.md new file mode 100644 index 0000000..e0a5368 --- /dev/null +++ b/lib/pymthouse/README.md @@ -0,0 +1,203 @@ +# PmtHouse SDK Integration (Website) + +This module provides a server-safe, SDK-style integration layer for PymtHouse in the `website` app. It is designed to be reusable, typed, and explicit about OAuth/OIDC boundaries. + +The implementation follows the contracts in: + +- [`pymthouse/docs/builder-api.md`](/home/elite/repos/pymthouse/docs/builder-api.md) +- [`pymthouse/docs/naap-oidc-integration.md`](/home/elite/repos/pymthouse/docs/naap-oidc-integration.md) +- [`pymthouse/docs/usage-api.md`](/home/elite/repos/pymthouse/docs/usage-api.md) + +## Standards Alignment + +The flow uses the same standards and grant types documented by PymtHouse: + +- OAuth 2.0 (RFC 6749) +- Bearer token usage (RFC 6750) +- Device Authorization Grant (RFC 8628) +- Token Exchange (RFC 8693) +- Resource Indicators (RFC 8707) +- JWT access token profile (RFC 9068) + +## Environment Configuration + +Set the following in your runtime environment: + +```bash +PYMTHOUSE_ISSUER_URL=http://localhost:3001/api/v1/oidc +PYMTHOUSE_PUBLIC_CLIENT_ID=app_... +PYMTHOUSE_M2M_CLIENT_ID=m2m_... +PYMTHOUSE_M2M_CLIENT_SECRET=pmth_cs_... +LP_SESSION_SECRET= +``` + +Notes: + +- `PYMTHOUSE_PUBLIC_CLIENT_ID` is the `app_...` client used in Builder path tenancy (`/api/v1/apps/{clientId}/...`). +- `PYMTHOUSE_M2M_CLIENT_ID` / `PYMTHOUSE_M2M_CLIENT_SECRET` authenticate confidential server-to-server calls. + +## Module Layout + +- `client.ts`: Main `PmtHouseClient` class +- `discovery.ts`: OIDC discovery fetch + cache +- `errors.ts`: structured `PmtHouseError` +- `types.ts`: request/response types +- `format.ts`: wei formatting helpers +- `server.ts`: singleton factory (`getPmtHouseClient()`) + +## Typical Usage + +```ts +import { getPmtHouseClient } from "@/lib/pymthouse"; + +const client = getPmtHouseClient(); +const discovery = await client.getDiscovery(); +console.log(discovery.issuer); +``` + +## API Reference + +### `getDiscovery()` + +Fetches and caches `{issuer}/.well-known/openid-configuration`. + +```ts +const metadata = await client.getDiscovery(); +``` + +### `verifyIssuer(iss)` + +Exact issuer match guard used for third-party initiate-login callbacks. + +```ts +if (!client.verifyIssuer(issFromQuery)) { + throw new Error("Issuer mismatch"); +} +``` + +### `parseDeviceApprovalRedirect(searchParams)` + +Parses and validates `iss` + `target_link_uri`, and extracts `user_code`/`client_id`. + +```ts +const parsed = client.parseDeviceApprovalRedirect(request.nextUrl.searchParams); +// parsed.userCode -> normalized RFC 8628 user code +``` + +### `upsertAppUser({ externalUserId, email, status })` + +Calls Builder API `POST /api/v1/apps/{clientId}/users`. + +```ts +await client.upsertAppUser({ + externalUserId: "ext_abc123", + email: "dev@example.com", + status: "active", +}); +``` + +### `mintUserAccessToken({ externalUserId, scope })` + +Calls Builder API `POST /api/v1/apps/{clientId}/users/{externalUserId}/token`. + +```ts +const userToken = await client.mintUserAccessToken({ + externalUserId: "ext_abc123", + scope: "sign:job", +}); +``` + +### `completeDeviceApproval({ userJwt, userCode })` + +Completes NaaP Option B device approval via RFC 8693: + +`POST {issuer}/token` with `resource=urn:pmth:device_code:`. + +```ts +await client.completeDeviceApproval({ + userJwt: userToken.access_token, + userCode: "ABCD-EFGH", +}); +``` + +### `exchangeForSignerSession({ userJwt })` + +Performs RFC 8693 gateway token exchange to obtain a `pmth_...` session token. + +```ts +const signer = await client.exchangeForSignerSession({ + userJwt: userToken.access_token, +}); +``` + +### `createSignerSessionToken({ userJwt })` + +Production-safe helper used by the website token endpoint: + +1. First attempts user-JWT exchange. +2. Falls back to `client_credentials` + gateway exchange when server policy rejects user-JWT exchange for this client pairing. + +```ts +const signer = await client.createSignerSessionToken({ + userJwt: session.pmthUserJwt, +}); +``` + +### `getUsage({ startDate, endDate, groupBy, userId })` + +Calls Usage API with Basic auth and returns typed totals/by-user payload. + +```ts +const usage = await client.getUsage({ + startDate: "2026-01-01T00:00:00.000Z", + endDate: "2026-01-31T23:59:59.999Z", + groupBy: "user", +}); +``` + +## NaaP Option B Flow in Website + +The website follows this contract: + +1. Browser lands on `/api/auth/initiate-login` with `iss` and `target_link_uri`. +2. Website validates issuer and target link. +3. If user is not signed in, website stores a short-lived device-flow cookie and redirects to `/studio/login?flow=device`. +4. On login, website: + - upserts app user, + - mints user JWT via Builder API, + - completes RFC 8693 device approval. +5. Browser is redirected to `/studio/device-approved` while the CLI continues polling. + +## Error Taxonomy + +`PmtHouseError` includes: + +- `status`: HTTP status from the upstream failure surface +- `code`: normalized machine code (for example, `invalid_client`, `invalid_scope`, `invalid_grant`, `invalid_target`) +- `details`: original upstream response payload when available + +Use route handlers to pass through these fields without leaking secrets. + +## Security Boundaries + +- Keep `m2m` secret server-side only; never expose in client bundles. +- Keep long-lived user JWTs in httpOnly signed session cookie (`lp_session`). +- Use short-lived browser JWT (`lp_browser_jwt`) for browser-readable claims. +- Validate `iss` and `target_link_uri` strictly for third-party initiate-login. +- Normalize and validate RFC 8628 `user_code` before token exchange. + +## Key Design Decisions and Trade-offs + +1. **Framework-agnostic core client**: easier extraction into a standalone package later. +2. **Discovery-first endpoints**: avoids hard-coded token endpoint drift. +3. **Session metadata for token list**: no remote list/revoke API requirement; supports current UI quickly. +4. **Gateway exchange fallback path**: maintains `pmth_` token UX even when user-JWT exchange is constrained by current server policy. +5. **BigInt-safe fee handling**: wire `wei` as strings until render-time formatting. + +## Implementation Tasks + +- Confirm all required env vars are present in deployment targets. +- Verify device flow end-to-end from `python-gateway` to `/studio/device-approved`. +- Validate that `/api/tokens` emits real `pmth_` values in your environment. +- Validate `/api/usage` values against known rows in PymtHouse for at least one date range. +- Add persistent token metadata storage (DB) if cross-device visibility is required. diff --git a/lib/pymthouse/client.ts b/lib/pymthouse/client.ts new file mode 100644 index 0000000..bef40ef --- /dev/null +++ b/lib/pymthouse/client.ts @@ -0,0 +1,351 @@ +import { fetchDiscoveryDocument } from "@/lib/pymthouse/discovery"; +import { PmtHouseError } from "@/lib/pymthouse/errors"; +import type { + AppUserRecord, + ClientCredentialsTokenResponse, + DeviceApprovalInput, + FetchLike, + MintUserAccessTokenInput, + MintUserAccessTokenResponse, + OidcDiscoveryDocument, + ParsedDeviceApprovalRedirect, + PmtHouseClientOptions, + TokenExchangeResponse, + UpsertAppUserInput, + UsageApiResponse, + UsageQueryInput, +} from "@/lib/pymthouse/types"; + +const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; +const SUBJECT_ACCESS_TOKEN_TYPE = + "urn:ietf:params:oauth:token-type:access_token"; +const DEVICE_RESOURCE_PREFIX = "urn:pmth:device_code:"; + +export class PmtHouseClient { + private readonly issuerUrl: string; + private readonly publicClientId: string; + private readonly m2mClientId: string; + private readonly m2mClientSecret: string; + private readonly fetchImpl: FetchLike; + private readonly logger?: PmtHouseClientOptions["logger"]; + + constructor(options: PmtHouseClientOptions) { + this.issuerUrl = options.issuerUrl.replace(/\/+$/, ""); + this.publicClientId = options.publicClientId; + this.m2mClientId = options.m2mClientId; + this.m2mClientSecret = options.m2mClientSecret; + this.fetchImpl = options.fetch ?? fetch; + this.logger = options.logger; + } + + async getDiscovery(): Promise { + return fetchDiscoveryDocument(this.issuerUrl, this.fetchImpl); + } + + verifyIssuer(iss: string): boolean { + const candidate = iss.trim().replace(/\/+$/, ""); + return candidate === this.issuerUrl; + } + + parseDeviceApprovalRedirect( + searchParams: URLSearchParams, + ): ParsedDeviceApprovalRedirect { + const issuer = searchParams.get("iss")?.trim() ?? ""; + const targetLinkUri = searchParams.get("target_link_uri")?.trim() ?? ""; + + if (!issuer || !targetLinkUri) { + throw new PmtHouseError("Missing iss or target_link_uri", { + status: 400, + code: "invalid_request", + }); + } + + if (!this.verifyIssuer(issuer)) { + throw new PmtHouseError("Issuer mismatch for initiate login", { + status: 400, + code: "invalid_issuer", + }); + } + + let targetUrl: URL; + try { + targetUrl = new URL(targetLinkUri); + } catch { + throw new PmtHouseError("target_link_uri is not a valid URL", { + status: 400, + code: "invalid_target", + }); + } + + const issuerOrigin = new URL(this.issuerUrl).origin; + if (targetUrl.origin !== issuerOrigin || targetUrl.pathname !== "/oidc/device") { + throw new PmtHouseError("target_link_uri does not point to the issuer device path", { + status: 400, + code: "invalid_target", + }); + } + + const userCode = this.normalizeUserCode( + targetUrl.searchParams.get("user_code") ?? "", + ); + const clientId = targetUrl.searchParams.get("client_id")?.trim() ?? ""; + + if (!userCode || !clientId) { + throw new PmtHouseError("target_link_uri is missing user_code or client_id", { + status: 400, + code: "invalid_target", + }); + } + + return { + issuer, + targetLinkUri, + userCode, + clientId, + }; + } + + async listAppUsers(): Promise<{ users: AppUserRecord[] }> { + const url = `${this.getAppsBaseUrl()}/users`; + return this.requestJson<{ users: AppUserRecord[] }>(url, { + method: "GET", + headers: this.builderHeaders(), + cache: "no-store", + }); + } + + async upsertAppUser(input: UpsertAppUserInput): Promise { + const payload: Record = { + externalUserId: input.externalUserId, + }; + if (input.email) payload.email = input.email; + if (input.status) payload.status = input.status; + + const url = `${this.getAppsBaseUrl()}/users`; + return this.requestJson(url, { + method: "POST", + headers: this.builderHeaders(), + body: JSON.stringify(payload), + cache: "no-store", + }); + } + + async deleteAppUser(params: { externalUserId: string }): Promise<{ success: boolean }> { + const url = new URL(`${this.getAppsBaseUrl()}/users`); + url.searchParams.set("externalUserId", params.externalUserId); + return this.requestJson<{ success: boolean }>(url.toString(), { + method: "DELETE", + headers: this.builderHeaders(), + cache: "no-store", + }); + } + + async mintUserAccessToken( + input: MintUserAccessTokenInput, + ): Promise { + const url = `${this.getAppsBaseUrl()}/users/${encodeURIComponent(input.externalUserId)}/token`; + const body = input.scope ? { scope: input.scope } : {}; + + return this.requestJson(url, { + method: "POST", + headers: this.builderHeaders(), + body: JSON.stringify(body), + cache: "no-store", + }); + } + + async completeDeviceApproval( + input: DeviceApprovalInput, + ): Promise { + const discovery = await this.getDiscovery(); + const form = new URLSearchParams(); + form.set("grant_type", TOKEN_EXCHANGE_GRANT); + form.set("subject_token", input.userJwt); + form.set("subject_token_type", SUBJECT_ACCESS_TOKEN_TYPE); + form.set( + "resource", + `${DEVICE_RESOURCE_PREFIX}${this.normalizeUserCode(input.userCode)}`, + ); + + return this.requestJson(discovery.token_endpoint, { + method: "POST", + headers: this.oidcFormHeaders(), + body: form.toString(), + cache: "no-store", + }); + } + + async issueMachineAccessToken( + scope = "sign:job", + ): Promise { + const discovery = await this.getDiscovery(); + const form = new URLSearchParams(); + form.set("grant_type", "client_credentials"); + form.set("scope", scope); + + return this.requestJson(discovery.token_endpoint, { + method: "POST", + headers: this.oidcFormHeaders(), + body: form.toString(), + cache: "no-store", + }); + } + + async exchangeForSignerSession(input: { + userJwt: string; + }): Promise { + const discovery = await this.getDiscovery(); + const form = new URLSearchParams(); + form.set("grant_type", TOKEN_EXCHANGE_GRANT); + form.set("subject_token", input.userJwt); + form.set("subject_token_type", SUBJECT_ACCESS_TOKEN_TYPE); + form.set("scope", "sign:job"); + + return this.requestJson(discovery.token_endpoint, { + method: "POST", + headers: this.oidcFormHeaders(), + body: form.toString(), + cache: "no-store", + }); + } + + async createSignerSessionToken(params: { + userJwt?: string; + }): Promise { + if (params.userJwt) { + try { + return await this.exchangeForSignerSession({ userJwt: params.userJwt }); + } catch (error) { + const err = this.asError(error); + this.logger?.warn?.("User JWT exchange failed, falling back to machine exchange", { + code: err.code, + status: err.status, + }); + } + } + + const machineToken = await this.issueMachineAccessToken("sign:job"); + if (!machineToken.access_token) { + throw new PmtHouseError("Client credentials flow did not return access_token", { + status: 502, + code: "invalid_token_response", + }); + } + + return this.exchangeForSignerSession({ userJwt: machineToken.access_token }); + } + + async getUsage(input: UsageQueryInput = {}): Promise { + const url = new URL(`${this.getAppsBaseUrl()}/usage`); + if (input.startDate) url.searchParams.set("startDate", input.startDate); + if (input.endDate) url.searchParams.set("endDate", input.endDate); + if (input.groupBy) url.searchParams.set("groupBy", input.groupBy); + if (input.userId) url.searchParams.set("userId", input.userId); + + return this.requestJson(url.toString(), { + method: "GET", + headers: this.builderHeaders(), + cache: "no-store", + }); + } + + private normalizeUserCode(value: string): string { + return value + .replace(/[a-z]/g, (char) => char.toUpperCase()) + .replace(/\W/g, ""); + } + + private getAppsBaseUrl(): string { + return `${this.getIssuerOrigin()}/api/v1/apps/${encodeURIComponent(this.publicClientId)}`; + } + + private getIssuerOrigin(): string { + return new URL(this.issuerUrl).origin; + } + + private builderHeaders(): HeadersInit { + return { + Authorization: this.basicAuthorizationHeader(), + "Content-Type": "application/json", + Accept: "application/json", + }; + } + + private oidcFormHeaders(): HeadersInit { + return { + Authorization: this.basicAuthorizationHeader(), + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + } + + private basicAuthorizationHeader(): string { + const raw = `${this.m2mClientId}:${this.m2mClientSecret}`; + return `Basic ${Buffer.from(raw).toString("base64")}`; + } + + private async requestJson( + url: string, + init: RequestInit, + ): Promise { + this.logger?.debug?.("PmtHouse request", { + method: init.method ?? "GET", + url, + }); + + const response = await this.fetchImpl(url, init); + const raw = await response.text(); + const parsed = raw ? this.safeParseJson(raw) : null; + + if (!response.ok) { + const details = (parsed ?? {}) as Record; + const description = + typeof details.error_description === "string" + ? details.error_description + : typeof details.error === "string" + ? details.error + : `Request failed (${response.status})`; + + throw new PmtHouseError(description, { + status: response.status, + code: + typeof details.error === "string" + ? details.error + : "pymthouse_http_error", + details, + }); + } + + if (!parsed) { + return {} as T; + } + + return parsed as T; + } + + private safeParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } + } + + private asError(error: unknown): PmtHouseError { + if (error instanceof PmtHouseError) { + return error; + } + + if (error instanceof Error) { + return new PmtHouseError(error.message, { + code: "unexpected_error", + status: 500, + }); + } + + return new PmtHouseError("Unexpected error", { + code: "unexpected_error", + status: 500, + }); + } +} diff --git a/lib/pymthouse/discovery.ts b/lib/pymthouse/discovery.ts new file mode 100644 index 0000000..1f0cfea --- /dev/null +++ b/lib/pymthouse/discovery.ts @@ -0,0 +1,76 @@ +import type { FetchLike, OidcDiscoveryDocument } from "@/lib/pymthouse/types"; +import { PmtHouseError } from "@/lib/pymthouse/errors"; + +const CACHE_TTL_MS = 5 * 60 * 1000; + +type CacheEntry = { + document: OidcDiscoveryDocument; + fetchedAt: number; +}; + +const discoveryCache = new Map(); + +export async function fetchDiscoveryDocument( + issuerUrl: string, + fetchImpl: FetchLike, +): Promise { + const normalizedIssuer = issuerUrl.replace(/\/+$/, ""); + const cached = discoveryCache.get(normalizedIssuer); + const now = Date.now(); + + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) { + return cached.document; + } + + const discoveryUrl = + `${normalizedIssuer}/.well-known/openid-configuration`; + + const response = await fetchImpl(discoveryUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + throw new PmtHouseError( + `Failed to load OIDC discovery (${response.status})`, + { + status: response.status, + code: "oidc_discovery_failed", + }, + ); + } + + const payload = (await response.json()) as Partial; + + if (!payload.issuer || !payload.token_endpoint || !payload.jwks_uri) { + throw new PmtHouseError("OIDC discovery document is missing fields", { + status: 500, + code: "oidc_discovery_invalid", + details: payload, + }); + } + + const document: OidcDiscoveryDocument = { + issuer: payload.issuer, + authorization_endpoint: payload.authorization_endpoint ?? "", + token_endpoint: payload.token_endpoint, + jwks_uri: payload.jwks_uri, + userinfo_endpoint: payload.userinfo_endpoint, + device_authorization_endpoint: payload.device_authorization_endpoint, + }; + + discoveryCache.set(normalizedIssuer, { document, fetchedAt: now }); + return document; +} + +export function clearDiscoveryCache(issuerUrl?: string): void { + if (!issuerUrl) { + discoveryCache.clear(); + return; + } + + discoveryCache.delete(issuerUrl.replace(/\/+$/, "")); +} diff --git a/lib/pymthouse/errors.ts b/lib/pymthouse/errors.ts new file mode 100644 index 0000000..7d3e6b5 --- /dev/null +++ b/lib/pymthouse/errors.ts @@ -0,0 +1,45 @@ +export class PmtHouseError extends Error { + readonly status: number; + readonly code: string; + readonly details?: unknown; + + constructor( + message: string, + { + status = 500, + code = "pymthouse_error", + details, + }: { + status?: number; + code?: string; + details?: unknown; + } = {}, + ) { + super(message); + this.name = "PmtHouseError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +export function toPmtHouseError( + error: unknown, + fallbackMessage: string, +): PmtHouseError { + if (error instanceof PmtHouseError) { + return error; + } + + if (error instanceof Error) { + return new PmtHouseError(error.message || fallbackMessage, { + code: "unexpected_error", + status: 500, + }); + } + + return new PmtHouseError(fallbackMessage, { + code: "unexpected_error", + status: 500, + }); +} diff --git a/lib/pymthouse/format.ts b/lib/pymthouse/format.ts new file mode 100644 index 0000000..efd0966 --- /dev/null +++ b/lib/pymthouse/format.ts @@ -0,0 +1,24 @@ +const WEI_PER_ETH = BigInt("1000000000000000000"); + +export function formatWeiToEth(wei: string): string { + const weiValue = BigInt(wei || "0"); + const whole = weiValue / WEI_PER_ETH; + const fraction = weiValue % WEI_PER_ETH; + const fractionStr = fraction.toString().padStart(18, "0").slice(0, 6); + if (fractionStr === "000000") { + return `${whole.toString()} ETH`; + } + return `${whole.toString()}.${fractionStr} ETH`; +} + +export function formatWeiToUsd( + wei: string, + usdPerEth = 3500, +): string { + const weiValue = BigInt(wei || "0"); + const whole = weiValue / WEI_PER_ETH; + const fraction = weiValue % WEI_PER_ETH; + const eth = Number(whole) + Number(fraction) / 1e18; + const usd = eth * usdPerEth; + return `$${usd.toFixed(2)}`; +} diff --git a/lib/pymthouse/index.ts b/lib/pymthouse/index.ts new file mode 100644 index 0000000..8d8bc12 --- /dev/null +++ b/lib/pymthouse/index.ts @@ -0,0 +1,20 @@ +export { PmtHouseClient } from "@/lib/pymthouse/client"; +export { PmtHouseError, toPmtHouseError } from "@/lib/pymthouse/errors"; +export { formatWeiToEth, formatWeiToUsd } from "@/lib/pymthouse/format"; +export { getPmtHouseClient } from "@/lib/pymthouse/server"; +export type { + AppUserRecord, + ClientCredentialsTokenResponse, + DeviceApprovalInput, + MintUserAccessTokenInput, + MintUserAccessTokenResponse, + OidcDiscoveryDocument, + ParsedDeviceApprovalRedirect, + PmtHouseClientOptions, + TokenExchangeResponse, + UpsertAppUserInput, + UsageApiResponse, + UsageByUserRow, + UsageQueryInput, + UsageTotals, +} from "@/lib/pymthouse/types"; diff --git a/lib/pymthouse/server.ts b/lib/pymthouse/server.ts new file mode 100644 index 0000000..fb7b0a5 --- /dev/null +++ b/lib/pymthouse/server.ts @@ -0,0 +1,41 @@ +import { PmtHouseClient } from "@/lib/pymthouse/client"; +import { PmtHouseError } from "@/lib/pymthouse/errors"; + +let cachedClient: PmtHouseClient | null = null; + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (value && value.trim()) { + return value.trim(); + } + + throw new PmtHouseError(`Missing required environment variable: ${name}`, { + status: 500, + code: "missing_env", + }); +} + +export function getPmtHouseClient(): PmtHouseClient { + if (cachedClient) { + return cachedClient; + } + + cachedClient = new PmtHouseClient({ + issuerUrl: requiredEnv("PYMTHOUSE_ISSUER_URL"), + publicClientId: requiredEnv("PYMTHOUSE_PUBLIC_CLIENT_ID"), + m2mClientId: requiredEnv("PYMTHOUSE_M2M_CLIENT_ID"), + m2mClientSecret: requiredEnv("PYMTHOUSE_M2M_CLIENT_SECRET"), + logger: { + debug: (message, details) => { + if (process.env.NODE_ENV !== "production") { + console.debug(`[pymthouse] ${message}`, details ?? {}); + } + }, + warn: (message, details) => { + console.warn(`[pymthouse] ${message}`, details ?? {}); + }, + }, + }); + + return cachedClient; +} diff --git a/lib/pymthouse/types.ts b/lib/pymthouse/types.ts new file mode 100644 index 0000000..33d8ffd --- /dev/null +++ b/lib/pymthouse/types.ts @@ -0,0 +1,115 @@ +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export interface OidcDiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + userinfo_endpoint?: string; + device_authorization_endpoint?: string; +} + +export interface PmtHouseClientOptions { + issuerUrl: string; + publicClientId: string; + m2mClientId: string; + m2mClientSecret: string; + fetch?: FetchLike; + logger?: { + debug?: (message: string, details?: Record) => void; + warn?: (message: string, details?: Record) => void; + }; +} + +export interface UpsertAppUserInput { + externalUserId: string; + email?: string; + status?: "active" | "inactive"; +} + +export interface AppUserRecord { + id: string; + clientId: string; + externalUserId: string; + email: string | null; + status: string; + role: string; + createdAt: string; +} + +export interface MintUserAccessTokenInput { + externalUserId: string; + scope?: string; +} + +export interface MintUserAccessTokenResponse { + access_token: string; + refresh_token: string; + token_type: "Bearer"; + expires_in: number; + scope: string; + subject_type: "app_user"; + correlation_id?: string; +} + +export interface DeviceApprovalInput { + userJwt: string; + userCode: string; +} + +export interface TokenExchangeResponse { + access_token: string; + token_type: "Bearer"; + expires_in: number; + scope: string; + issued_token_type: string; +} + +export interface UsageQueryInput { + startDate?: string; + endDate?: string; + groupBy?: "none" | "user"; + userId?: string; +} + +export interface UsageTotals { + requestCount: number; + totalFeeWei: string; +} + +export interface UsageByUserRow { + endUserId: string; + externalUserId: string | null; + requestCount: number; + feeWei: string; + userType?: "system_managed" | "oidc_authorized" | "unknown"; + identifier?: string; +} + +export interface UsageApiResponse { + clientId: string; + period: { + start: string | null; + end: string | null; + }; + totals: UsageTotals; + byUser?: UsageByUserRow[]; +} + +export interface ClientCredentialsTokenResponse { + access_token: string; + token_type: "Bearer"; + expires_in?: number; + scope?: string; + [key: string]: unknown; +} + +export interface ParsedDeviceApprovalRedirect { + issuer: string; + targetLinkUri: string; + userCode: string; + clientId: string; +} diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..e001b61 --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,326 @@ +import { SignJWT, jwtVerify, type JWTPayload } from "jose"; +import type { NextRequest, NextResponse } from "next/server"; + +export const SESSION_COOKIE_NAME = "lp_session"; +export const SESSION_USER_COOKIE_NAME = "lp_session_user"; +export const BROWSER_JWT_COOKIE_NAME = "lp_browser_jwt"; +export const DEVICE_FLOW_COOKIE_NAME = "lp_device_flow"; + +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +const BROWSER_JWT_TTL_SECONDS = 60 * 15; +const DEVICE_FLOW_TTL_SECONDS = 60 * 10; + +type StudioAuthProvider = "github" | "google" | "email"; + +export type StoredApiTokenMeta = { + id: string; + name: string; + prefix: string; + createdAt: string; + lastUsedAt: string | null; + status: "active" | "revoked"; +}; + +export interface SessionPayload extends JWTPayload { + sub: string; + externalUserId: string; + email: string; + name: string; + initials: string; + provider: StudioAuthProvider; + pmthUserJwt: string; + apiTokens: StoredApiTokenMeta[]; +} + +export interface SessionPublicUser { + externalUserId: string; + email: string; + name: string; + initials: string; + provider: StudioAuthProvider; + hasPmthouseBinding: boolean; +} + +export interface IssueSessionInput { + externalUserId: string; + email: string; + name: string; + initials: string; + provider: StudioAuthProvider; + pmthUserJwt: string; + apiTokens?: StoredApiTokenMeta[]; +} + +export interface DeviceFlowPayload extends JWTPayload { + iss: string; + targetLinkUri: string; + userCode: string; + clientId: string; +} + +function getSessionSecret(): Uint8Array { + const raw = process.env.LP_SESSION_SECRET; + if (raw && raw.trim()) { + return new TextEncoder().encode(raw.trim()); + } + + if (process.env.NODE_ENV === "production") { + throw new Error("LP_SESSION_SECRET is required in production"); + } + + return new TextEncoder().encode("dev-only-unsafe-session-secret"); +} + +async function signJwt( + payload: JWTPayload, + expiresInSeconds: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + expiresInSeconds) + .sign(getSessionSecret()); +} + +export async function createSessionToken( + input: IssueSessionInput, +): Promise { + const payload: SessionPayload = { + sub: input.externalUserId, + externalUserId: input.externalUserId, + email: input.email, + name: input.name, + initials: input.initials, + provider: input.provider, + pmthUserJwt: input.pmthUserJwt, + apiTokens: input.apiTokens ?? [], + }; + return signJwt(payload, SESSION_TTL_SECONDS); +} + +export async function createBrowserJwt( + payload: SessionPublicUser, +): Promise { + return signJwt( + { + sub: payload.externalUserId, + email: payload.email, + name: payload.name, + initials: payload.initials, + provider: payload.provider, + hasPmthouseBinding: payload.hasPmthouseBinding, + aud: "studio-browser", + }, + BROWSER_JWT_TTL_SECONDS, + ); +} + +export async function verifySessionToken( + token: string, +): Promise { + try { + const { payload } = await jwtVerify(token, getSessionSecret(), { + algorithms: ["HS256"], + }); + if ( + typeof payload.sub !== "string" || + typeof payload.externalUserId !== "string" || + typeof payload.pmthUserJwt !== "string" + ) { + return null; + } + + return { + ...payload, + sub: payload.sub, + externalUserId: payload.externalUserId, + email: String(payload.email ?? ""), + name: String(payload.name ?? ""), + initials: String(payload.initials ?? ""), + provider: (payload.provider as StudioAuthProvider) ?? "email", + pmthUserJwt: payload.pmthUserJwt, + apiTokens: Array.isArray(payload.apiTokens) + ? (payload.apiTokens as StoredApiTokenMeta[]) + : [], + }; + } catch { + return null; + } +} + +export async function createDeviceFlowToken( + payload: Omit, +): Promise { + return signJwt(payload, DEVICE_FLOW_TTL_SECONDS); +} + +export async function verifyDeviceFlowToken( + token: string, +): Promise { + try { + const { payload } = await jwtVerify(token, getSessionSecret(), { + algorithms: ["HS256"], + }); + if ( + typeof payload.iss !== "string" || + typeof payload.targetLinkUri !== "string" || + typeof payload.userCode !== "string" || + typeof payload.clientId !== "string" + ) { + return null; + } + + return { + ...payload, + iss: payload.iss, + targetLinkUri: payload.targetLinkUri, + userCode: payload.userCode, + clientId: payload.clientId, + }; + } catch { + return null; + } +} + +export async function readSessionFromRequest( + request: NextRequest, +): Promise { + const token = request.cookies.get(SESSION_COOKIE_NAME)?.value; + if (!token) { + return null; + } + return verifySessionToken(token); +} + +export async function readDeviceFlowFromRequest( + request: NextRequest, +): Promise { + const token = request.cookies.get(DEVICE_FLOW_COOKIE_NAME)?.value; + if (!token) { + return null; + } + return verifyDeviceFlowToken(token); +} + +export function toSessionPublicUser(payload: SessionPayload): SessionPublicUser { + return { + externalUserId: payload.externalUserId, + email: payload.email, + name: payload.name, + initials: payload.initials, + provider: payload.provider, + hasPmthouseBinding: Boolean(payload.pmthUserJwt), + }; +} + +export async function applySessionCookies( + response: NextResponse, + input: IssueSessionInput, +): Promise { + const sessionToken = await createSessionToken(input); + const publicUser = toSessionPublicUser({ + ...input, + sub: input.externalUserId, + apiTokens: input.apiTokens ?? [], + }); + const browserJwt = await createBrowserJwt(publicUser); + + const secure = process.env.NODE_ENV === "production"; + response.cookies.set(SESSION_COOKIE_NAME, sessionToken, { + httpOnly: true, + sameSite: "lax", + secure, + path: "/", + maxAge: SESSION_TTL_SECONDS, + }); + response.cookies.set( + SESSION_USER_COOKIE_NAME, + Buffer.from(JSON.stringify(publicUser), "utf-8").toString("base64"), + { + httpOnly: false, + sameSite: "lax", + secure, + path: "/", + maxAge: SESSION_TTL_SECONDS, + }, + ); + response.cookies.set(BROWSER_JWT_COOKIE_NAME, browserJwt, { + httpOnly: false, + sameSite: "lax", + secure, + path: "/", + maxAge: BROWSER_JWT_TTL_SECONDS, + }); +} + +export function clearSessionCookies(response: NextResponse): void { + response.cookies.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + response.cookies.set(SESSION_USER_COOKIE_NAME, "", { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + response.cookies.set(BROWSER_JWT_COOKIE_NAME, "", { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + clearDeviceFlowCookie(response); +} + +export async function setDeviceFlowCookie( + response: NextResponse, + payload: Omit, +): Promise { + const token = await createDeviceFlowToken(payload); + response.cookies.set(DEVICE_FLOW_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: DEVICE_FLOW_TTL_SECONDS, + }); +} + +export function clearDeviceFlowCookie(response: NextResponse): void { + response.cookies.set(DEVICE_FLOW_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); +} + +export function upsertApiTokenMeta( + payload: SessionPayload, + token: StoredApiTokenMeta, +): StoredApiTokenMeta[] { + const filtered = payload.apiTokens.filter((item) => item.id !== token.id); + return [...filtered, token]; +} + +export function revokeApiTokenMeta( + payload: SessionPayload, + tokenId: string, +): StoredApiTokenMeta[] { + return payload.apiTokens.map((item) => + item.id === tokenId + ? { + ...item, + status: "revoked", + } + : item, + ); +} diff --git a/lib/studio-auth.ts b/lib/studio-auth.ts new file mode 100644 index 0000000..1417df9 --- /dev/null +++ b/lib/studio-auth.ts @@ -0,0 +1,63 @@ +import { createHash } from "crypto"; + +export type StudioAuthProvider = "github" | "google" | "email"; + +export interface StudioLoginProfile { + provider: StudioAuthProvider; + name: string; + email: string; + initials: string; +} + +const MOCK_PROVIDER_PROFILES: Record< + Exclude, + { name: string; email: string } +> = { + github: { + name: "Studio Developer", + email: "studio.github@example.com", + }, + google: { + name: "Studio Developer", + email: "studio.google@example.com", + }, +}; + +export function toInitials(name: string): string { + return name + .split(" ") + .map((word) => word.trim()) + .filter(Boolean) + .map((word) => word[0]) + .join("") + .toUpperCase() + .slice(0, 2) || "SU"; +} + +export function deriveExternalUserId(email: string): string { + const digest = createHash("sha256") + .update(email.trim().toLowerCase()) + .digest("hex") + .slice(0, 24); + return `ext_${digest}`; +} + +export function resolveLoginProfile(input: { + provider: StudioAuthProvider; + email?: string; + name?: string; +}): StudioLoginProfile { + const provider = input.provider; + const fallback = provider === "email" ? null : MOCK_PROVIDER_PROFILES[provider]; + + const email = input.email?.trim() || fallback?.email || "studio.user@example.com"; + const displayName = + input.name?.trim() || fallback?.name || email.split("@")[0] || "Studio User"; + + return { + provider, + name: displayName, + email, + initials: toInitials(displayName), + }; +} diff --git a/package.json b/package.json index f80defd..dfae896 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "framer-motion": "^11.15.0", "gray-matter": "^4.0.3", + "jose": "^6.2.2", "lucide-react": "^1.6.0", "next": "^15.1.0", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13cfc55..be71e63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + jose: + specifier: ^6.2.2 + version: 6.2.2 lucide-react: specifier: ^1.6.0 version: 1.7.0(react@19.2.4) @@ -1079,19 +1082,14 @@ packages: typescript: optional: true -<<<<<<< Updated upstream eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} -======= eslint-import-resolver-node@0.3.10: resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} ->>>>>>> Stashed changes eslint-import-resolver-typescript@3.10.1: resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} @@ -1535,6 +1533,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3266,15 +3267,11 @@ snapshots: - eslint-plugin-import-x - supports-color -<<<<<<< Updated upstream eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node@0.3.9: -======= eslint-import-resolver-node@0.3.10: ->>>>>>> Stashed changes dependencies: debug: 3.2.7 is-core-module: 2.16.1 @@ -3795,6 +3792,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: From c54caf3282c18e38855da1cd3ba66fe60cbb0bd3 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sun, 19 Apr 2026 20:28:01 -0400 Subject: [PATCH 2/5] feat: refactor device approval flow and enhance session management Updated the device approval process by introducing a new utility function for completing studio device approvals with Pymthouse. This change centralizes the logic for user approval and JWT minting, improving code maintainability. Enhanced session management by applying session cookies consistently across device approval routes. Adjusted the login page to handle device flow more effectively, ensuring a smoother user experience during authentication. --- app/api/auth/device/complete/route.ts | 20 ++++-- app/api/auth/initiate-login/route.ts | 15 ++++- app/api/auth/login/route.ts | 67 +++++-------------- components/studio/LoginPage.tsx | 25 ++++++- .../complete-studio-device-approval.ts | 44 ++++++++++++ 5 files changed, 112 insertions(+), 59 deletions(-) create mode 100644 lib/pymthouse/complete-studio-device-approval.ts diff --git a/app/api/auth/device/complete/route.ts b/app/api/auth/device/complete/route.ts index 607221d..edd8f33 100644 --- a/app/api/auth/device/complete/route.ts +++ b/app/api/auth/device/complete/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPmtHouseClient, PmtHouseError } from "@/lib/pymthouse"; +import { PmtHouseError } from "@/lib/pymthouse"; +import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pymthouse/complete-studio-device-approval"; import { + applySessionCookies, clearDeviceFlowCookie, readDeviceFlowFromRequest, readSessionFromRequest, @@ -30,9 +32,8 @@ export async function POST(request: NextRequest) { } try { - const client = getPmtHouseClient(); - await client.completeDeviceApproval({ - userJwt: session.pmthUserJwt, + const pmthUserJwt = await completeStudioDeviceApprovalWithPymthouse({ + session, userCode: deviceFlow.userCode, }); @@ -40,6 +41,17 @@ export async function POST(request: NextRequest) { success: true, redirectTo: "/studio/device-approved", }); + + await applySessionCookies(response, { + externalUserId: session.externalUserId, + email: session.email, + name: session.name, + initials: session.initials, + provider: session.provider, + pmthUserJwt, + apiTokens: session.apiTokens, + }); + clearDeviceFlowCookie(response); return response; } catch (error) { diff --git a/app/api/auth/initiate-login/route.ts b/app/api/auth/initiate-login/route.ts index cc5a610..ccbdd8a 100644 --- a/app/api/auth/initiate-login/route.ts +++ b/app/api/auth/initiate-login/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getPmtHouseClient } from "@/lib/pymthouse"; +import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pymthouse/complete-studio-device-approval"; import { + applySessionCookies, clearDeviceFlowCookie, readSessionFromRequest, setDeviceFlowCookie, @@ -27,13 +29,22 @@ export async function GET(request: NextRequest) { return response; } - await client.completeDeviceApproval({ - userJwt: session.pmthUserJwt, + const pmthUserJwt = await completeStudioDeviceApprovalWithPymthouse({ + session, userCode: parsed.userCode, }); const approvedUrl = new URL("/studio/device-approved", request.url); const response = NextResponse.redirect(approvedUrl); + await applySessionCookies(response, { + externalUserId: session.externalUserId, + email: session.email, + name: session.name, + initials: session.initials, + provider: session.provider, + pmthUserJwt, + apiTokens: session.apiTokens, + }); clearDeviceFlowCookie(response); return response; } catch (error) { diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 5d70742..7f3ee80 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,10 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPmtHouseClient, PmtHouseError } from "@/lib/pymthouse"; -import { - applySessionCookies, - clearDeviceFlowCookie, - readDeviceFlowFromRequest, -} from "@/lib/session"; +import { applySessionCookies } from "@/lib/session"; import { deriveExternalUserId, resolveLoginProfile, @@ -20,6 +15,11 @@ function toProvider(value: unknown): StudioAuthProvider { return "email"; } +/** + * Studio sign-in: website-only stub session. No Pymthouse. + * Device approval uses Pymthouse only from `/api/auth/device/complete` or + * `GET /api/auth/initiate-login` when a session already exists. + */ export async function POST(request: NextRequest) { try { const body = (await request.json().catch(() => ({}))) as Record; @@ -30,43 +30,20 @@ export async function POST(request: NextRequest) { name: typeof body.name === "string" ? body.name : undefined, }); - const client = getPmtHouseClient(); const externalUserId = deriveExternalUserId(profile.email); - await client.upsertAppUser({ - externalUserId, + const userPayload = { + name: profile.name, email: profile.email, - status: "active", - }); - - const userToken = await client.mintUserAccessToken({ - externalUserId, - scope: "sign:job", - }); - - const deviceFlow = await readDeviceFlowFromRequest(request); - let redirectTo = "/studio"; - let deviceApproved = false; - - if (deviceFlow) { - await client.completeDeviceApproval({ - userJwt: userToken.access_token, - userCode: deviceFlow.userCode, - }); - redirectTo = "/studio/device-approved"; - deviceApproved = true; - } + initials: profile.initials, + provider: profile.provider, + }; const response = NextResponse.json({ success: true, - redirectTo, - deviceApproved, - user: { - name: profile.name, - email: profile.email, - initials: profile.initials, - provider: profile.provider, - }, + redirectTo: "/studio", + deviceApproved: false, + user: userPayload, }); await applySessionCookies(response, { @@ -75,26 +52,12 @@ export async function POST(request: NextRequest) { name: profile.name, initials: profile.initials, provider: profile.provider, - pmthUserJwt: userToken.access_token, + pmthUserJwt: "", }); - if (deviceFlow) { - clearDeviceFlowCookie(response); - } - return response; } catch (error) { console.error("Studio login failed", error); - if (error instanceof PmtHouseError) { - return NextResponse.json( - { - error: error.code, - error_description: error.message, - }, - { status: error.status }, - ); - } - return NextResponse.json( { error: "login_failed", diff --git a/components/studio/LoginPage.tsx b/components/studio/LoginPage.tsx index 8aae5c5..9c9ddc1 100644 --- a/components/studio/LoginPage.tsx +++ b/components/studio/LoginPage.tsx @@ -92,7 +92,30 @@ export default function LoginPage() { connect(json.user); await refresh(); - router.push(json.redirectTo || "/studio"); + + let nextPath = json.redirectTo || "/studio"; + if (deviceFlow) { + const deviceRes = await fetch("/api/auth/device/complete", { + method: "POST", + credentials: "include", + }); + const deviceJson = (await deviceRes.json().catch(() => ({}))) as { + success?: boolean; + error_description?: string; + redirectTo?: string; + }; + if (!deviceRes.ok || !deviceJson.success) { + setSubmitError( + deviceJson.error_description || + "Could not complete device approval with Pymthouse.", + ); + return; + } + await refresh(); + nextPath = deviceJson.redirectTo || "/studio/device-approved"; + } + + router.push(nextPath); } catch { setSubmitError("Could not sign in. Please try again."); } finally { diff --git a/lib/pymthouse/complete-studio-device-approval.ts b/lib/pymthouse/complete-studio-device-approval.ts new file mode 100644 index 0000000..ae082c2 --- /dev/null +++ b/lib/pymthouse/complete-studio-device-approval.ts @@ -0,0 +1,44 @@ +import { getPmtHouseClient } from "@/lib/pymthouse/server"; +import { PmtHouseError } from "@/lib/pymthouse/errors"; +import type { SessionPayload } from "@/lib/session"; + +/** + * Studio device flow only: upsert app user, mint user JWT, RFC 8693 device approval. + * Not used for normal `/api/auth/login` (website stub session). + */ +export async function completeStudioDeviceApprovalWithPymthouse(params: { + session: SessionPayload; + userCode: string; +}): Promise { + if ( + !process.env.PYMTHOUSE_ISSUER_URL?.trim() || + !process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() || + !process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim() || + !process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim() + ) { + throw new PmtHouseError( + "Pymthouse is not configured. Set PYMTHOUSE_* environment variables.", + { status: 503, code: "pymthouse_required" }, + ); + } + + const client = getPmtHouseClient(); + + await client.upsertAppUser({ + externalUserId: params.session.externalUserId, + email: params.session.email, + status: "active", + }); + + const userToken = await client.mintUserAccessToken({ + externalUserId: params.session.externalUserId, + scope: "sign:job", + }); + + await client.completeDeviceApproval({ + userJwt: userToken.access_token, + userCode: params.userCode, + }); + + return userToken.access_token; +} From 59afb97b5ee80c2c85394c5bd2f6d7512f5769aa Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sun, 19 Apr 2026 23:26:49 -0400 Subject: [PATCH 3/5] feat: update PymtHouse integration and clean up environment configuration Enhanced the PymtHouse integration by adding a new dependency for the builder API and updating the environment configuration in `.env.example`. Improved the `.gitignore` to exclude certificate files. Refactored API routes to utilize the new builder API for device approval and session management, ensuring a more streamlined authentication process. Removed outdated PmtHouse client code and related files to simplify the codebase. --- .env.example | 1 + .gitignore | 2 + app/api/auth/device/complete/route.ts | 4 +- app/api/auth/initiate-login/route.ts | 6 +- app/api/tokens/route.ts | 5 +- app/api/usage/route.ts | 7 +- ...oval.ts => pmth-studio-device-approval.ts} | 6 +- lib/pymthouse/README.md | 203 ---------- lib/pymthouse/client.ts | 351 ------------------ lib/pymthouse/discovery.ts | 76 ---- lib/pymthouse/errors.ts | 45 --- lib/pymthouse/format.ts | 24 -- lib/pymthouse/index.ts | 20 - lib/pymthouse/server.ts | 41 -- lib/pymthouse/types.ts | 115 ------ package.json | 1 + pnpm-lock.yaml | 16 + 17 files changed, 35 insertions(+), 888 deletions(-) rename lib/{pymthouse/complete-studio-device-approval.ts => pmth-studio-device-approval.ts} (86%) delete mode 100644 lib/pymthouse/README.md delete mode 100644 lib/pymthouse/client.ts delete mode 100644 lib/pymthouse/discovery.ts delete mode 100644 lib/pymthouse/errors.ts delete mode 100644 lib/pymthouse/format.ts delete mode 100644 lib/pymthouse/index.ts delete mode 100644 lib/pymthouse/server.ts delete mode 100644 lib/pymthouse/types.ts diff --git a/.env.example b/.env.example index 3f64356..7459089 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ THEGRAPH_API_KEY= # PymtHouse integration (required for Studio auth/device flow) # Issuer must be the full OIDC issuer URL, e.g. http://localhost:3001/api/v1/oidc +# (site origin is derived from this URL in code — no PMTHOUSE_BASE_URL) PYMTHOUSE_ISSUER_URL= # Public OIDC client id (app_...) PYMTHOUSE_PUBLIC_CLIENT_ID= diff --git a/.gitignore b/.gitignore index 219d8bd..41ec2d2 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ img/ .vscode/ *.code-workspace .playwright-mcp/ + +certificates \ No newline at end of file diff --git a/app/api/auth/device/complete/route.ts b/app/api/auth/device/complete/route.ts index edd8f33..09f6d24 100644 --- a/app/api/auth/device/complete/route.ts +++ b/app/api/auth/device/complete/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { PmtHouseError } from "@/lib/pymthouse"; -import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pymthouse/complete-studio-device-approval"; +import { PmtHouseError } from "@pymthouse/builder-api"; +import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pmth-studio-device-approval"; import { applySessionCookies, clearDeviceFlowCookie, diff --git a/app/api/auth/initiate-login/route.ts b/app/api/auth/initiate-login/route.ts index ccbdd8a..5514524 100644 --- a/app/api/auth/initiate-login/route.ts +++ b/app/api/auth/initiate-login/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPmtHouseClient } from "@/lib/pymthouse"; -import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pymthouse/complete-studio-device-approval"; +import { createPmtHouseClientFromEnv } from "@pymthouse/builder-api/env"; +import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pmth-studio-device-approval"; import { applySessionCookies, clearDeviceFlowCookie, @@ -12,7 +12,7 @@ export const runtime = "nodejs"; export async function GET(request: NextRequest) { try { - const client = getPmtHouseClient(); + const client = createPmtHouseClientFromEnv(); const parsed = client.parseDeviceApprovalRedirect(request.nextUrl.searchParams); const session = await readSessionFromRequest(request); diff --git a/app/api/tokens/route.ts b/app/api/tokens/route.ts index 0570c40..827b03f 100644 --- a/app/api/tokens/route.ts +++ b/app/api/tokens/route.ts @@ -1,6 +1,7 @@ import { randomUUID } from "crypto"; import { NextRequest, NextResponse } from "next/server"; -import { getPmtHouseClient, PmtHouseError } from "@/lib/pymthouse"; +import { PmtHouseError } from "@pymthouse/builder-api"; +import { createPmtHouseClientFromEnv } from "@pymthouse/builder-api/env"; import { applySessionCookies, readSessionFromRequest, @@ -39,7 +40,7 @@ export async function POST(request: NextRequest) { ? body.name.trim() : "Token"; - const client = getPmtHouseClient(); + const client = createPmtHouseClientFromEnv(); const tokenResponse = await client.createSignerSessionToken({ userJwt: session.pmthUserJwt, }); diff --git a/app/api/usage/route.ts b/app/api/usage/route.ts index 15d6b99..8dbad41 100644 --- a/app/api/usage/route.ts +++ b/app/api/usage/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { formatWeiToUsd } from "@/lib/pymthouse/format"; -import { getPmtHouseClient, PmtHouseError } from "@/lib/pymthouse"; +import { PmtHouseError } from "@pymthouse/builder-api"; +import { createPmtHouseClientFromEnv } from "@pymthouse/builder-api/env"; +import { formatWeiToUsd } from "@pymthouse/builder-api/format"; import { readSessionFromRequest } from "@/lib/session"; export const runtime = "nodejs"; @@ -53,7 +54,7 @@ export async function GET(request: NextRequest) { const { days, startDate, endDate } = getPeriodWindow(period); try { - const client = getPmtHouseClient(); + const client = createPmtHouseClientFromEnv(); const usage = await client.getUsage({ startDate, endDate, diff --git a/lib/pymthouse/complete-studio-device-approval.ts b/lib/pmth-studio-device-approval.ts similarity index 86% rename from lib/pymthouse/complete-studio-device-approval.ts rename to lib/pmth-studio-device-approval.ts index ae082c2..67c9e23 100644 --- a/lib/pymthouse/complete-studio-device-approval.ts +++ b/lib/pmth-studio-device-approval.ts @@ -1,5 +1,5 @@ -import { getPmtHouseClient } from "@/lib/pymthouse/server"; -import { PmtHouseError } from "@/lib/pymthouse/errors"; +import { PmtHouseError } from "@pymthouse/builder-api"; +import { createPmtHouseClientFromEnv } from "@pymthouse/builder-api/env"; import type { SessionPayload } from "@/lib/session"; /** @@ -22,7 +22,7 @@ export async function completeStudioDeviceApprovalWithPymthouse(params: { ); } - const client = getPmtHouseClient(); + const client = createPmtHouseClientFromEnv(); await client.upsertAppUser({ externalUserId: params.session.externalUserId, diff --git a/lib/pymthouse/README.md b/lib/pymthouse/README.md deleted file mode 100644 index e0a5368..0000000 --- a/lib/pymthouse/README.md +++ /dev/null @@ -1,203 +0,0 @@ -# PmtHouse SDK Integration (Website) - -This module provides a server-safe, SDK-style integration layer for PymtHouse in the `website` app. It is designed to be reusable, typed, and explicit about OAuth/OIDC boundaries. - -The implementation follows the contracts in: - -- [`pymthouse/docs/builder-api.md`](/home/elite/repos/pymthouse/docs/builder-api.md) -- [`pymthouse/docs/naap-oidc-integration.md`](/home/elite/repos/pymthouse/docs/naap-oidc-integration.md) -- [`pymthouse/docs/usage-api.md`](/home/elite/repos/pymthouse/docs/usage-api.md) - -## Standards Alignment - -The flow uses the same standards and grant types documented by PymtHouse: - -- OAuth 2.0 (RFC 6749) -- Bearer token usage (RFC 6750) -- Device Authorization Grant (RFC 8628) -- Token Exchange (RFC 8693) -- Resource Indicators (RFC 8707) -- JWT access token profile (RFC 9068) - -## Environment Configuration - -Set the following in your runtime environment: - -```bash -PYMTHOUSE_ISSUER_URL=http://localhost:3001/api/v1/oidc -PYMTHOUSE_PUBLIC_CLIENT_ID=app_... -PYMTHOUSE_M2M_CLIENT_ID=m2m_... -PYMTHOUSE_M2M_CLIENT_SECRET=pmth_cs_... -LP_SESSION_SECRET= -``` - -Notes: - -- `PYMTHOUSE_PUBLIC_CLIENT_ID` is the `app_...` client used in Builder path tenancy (`/api/v1/apps/{clientId}/...`). -- `PYMTHOUSE_M2M_CLIENT_ID` / `PYMTHOUSE_M2M_CLIENT_SECRET` authenticate confidential server-to-server calls. - -## Module Layout - -- `client.ts`: Main `PmtHouseClient` class -- `discovery.ts`: OIDC discovery fetch + cache -- `errors.ts`: structured `PmtHouseError` -- `types.ts`: request/response types -- `format.ts`: wei formatting helpers -- `server.ts`: singleton factory (`getPmtHouseClient()`) - -## Typical Usage - -```ts -import { getPmtHouseClient } from "@/lib/pymthouse"; - -const client = getPmtHouseClient(); -const discovery = await client.getDiscovery(); -console.log(discovery.issuer); -``` - -## API Reference - -### `getDiscovery()` - -Fetches and caches `{issuer}/.well-known/openid-configuration`. - -```ts -const metadata = await client.getDiscovery(); -``` - -### `verifyIssuer(iss)` - -Exact issuer match guard used for third-party initiate-login callbacks. - -```ts -if (!client.verifyIssuer(issFromQuery)) { - throw new Error("Issuer mismatch"); -} -``` - -### `parseDeviceApprovalRedirect(searchParams)` - -Parses and validates `iss` + `target_link_uri`, and extracts `user_code`/`client_id`. - -```ts -const parsed = client.parseDeviceApprovalRedirect(request.nextUrl.searchParams); -// parsed.userCode -> normalized RFC 8628 user code -``` - -### `upsertAppUser({ externalUserId, email, status })` - -Calls Builder API `POST /api/v1/apps/{clientId}/users`. - -```ts -await client.upsertAppUser({ - externalUserId: "ext_abc123", - email: "dev@example.com", - status: "active", -}); -``` - -### `mintUserAccessToken({ externalUserId, scope })` - -Calls Builder API `POST /api/v1/apps/{clientId}/users/{externalUserId}/token`. - -```ts -const userToken = await client.mintUserAccessToken({ - externalUserId: "ext_abc123", - scope: "sign:job", -}); -``` - -### `completeDeviceApproval({ userJwt, userCode })` - -Completes NaaP Option B device approval via RFC 8693: - -`POST {issuer}/token` with `resource=urn:pmth:device_code:`. - -```ts -await client.completeDeviceApproval({ - userJwt: userToken.access_token, - userCode: "ABCD-EFGH", -}); -``` - -### `exchangeForSignerSession({ userJwt })` - -Performs RFC 8693 gateway token exchange to obtain a `pmth_...` session token. - -```ts -const signer = await client.exchangeForSignerSession({ - userJwt: userToken.access_token, -}); -``` - -### `createSignerSessionToken({ userJwt })` - -Production-safe helper used by the website token endpoint: - -1. First attempts user-JWT exchange. -2. Falls back to `client_credentials` + gateway exchange when server policy rejects user-JWT exchange for this client pairing. - -```ts -const signer = await client.createSignerSessionToken({ - userJwt: session.pmthUserJwt, -}); -``` - -### `getUsage({ startDate, endDate, groupBy, userId })` - -Calls Usage API with Basic auth and returns typed totals/by-user payload. - -```ts -const usage = await client.getUsage({ - startDate: "2026-01-01T00:00:00.000Z", - endDate: "2026-01-31T23:59:59.999Z", - groupBy: "user", -}); -``` - -## NaaP Option B Flow in Website - -The website follows this contract: - -1. Browser lands on `/api/auth/initiate-login` with `iss` and `target_link_uri`. -2. Website validates issuer and target link. -3. If user is not signed in, website stores a short-lived device-flow cookie and redirects to `/studio/login?flow=device`. -4. On login, website: - - upserts app user, - - mints user JWT via Builder API, - - completes RFC 8693 device approval. -5. Browser is redirected to `/studio/device-approved` while the CLI continues polling. - -## Error Taxonomy - -`PmtHouseError` includes: - -- `status`: HTTP status from the upstream failure surface -- `code`: normalized machine code (for example, `invalid_client`, `invalid_scope`, `invalid_grant`, `invalid_target`) -- `details`: original upstream response payload when available - -Use route handlers to pass through these fields without leaking secrets. - -## Security Boundaries - -- Keep `m2m` secret server-side only; never expose in client bundles. -- Keep long-lived user JWTs in httpOnly signed session cookie (`lp_session`). -- Use short-lived browser JWT (`lp_browser_jwt`) for browser-readable claims. -- Validate `iss` and `target_link_uri` strictly for third-party initiate-login. -- Normalize and validate RFC 8628 `user_code` before token exchange. - -## Key Design Decisions and Trade-offs - -1. **Framework-agnostic core client**: easier extraction into a standalone package later. -2. **Discovery-first endpoints**: avoids hard-coded token endpoint drift. -3. **Session metadata for token list**: no remote list/revoke API requirement; supports current UI quickly. -4. **Gateway exchange fallback path**: maintains `pmth_` token UX even when user-JWT exchange is constrained by current server policy. -5. **BigInt-safe fee handling**: wire `wei` as strings until render-time formatting. - -## Implementation Tasks - -- Confirm all required env vars are present in deployment targets. -- Verify device flow end-to-end from `python-gateway` to `/studio/device-approved`. -- Validate that `/api/tokens` emits real `pmth_` values in your environment. -- Validate `/api/usage` values against known rows in PymtHouse for at least one date range. -- Add persistent token metadata storage (DB) if cross-device visibility is required. diff --git a/lib/pymthouse/client.ts b/lib/pymthouse/client.ts deleted file mode 100644 index bef40ef..0000000 --- a/lib/pymthouse/client.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { fetchDiscoveryDocument } from "@/lib/pymthouse/discovery"; -import { PmtHouseError } from "@/lib/pymthouse/errors"; -import type { - AppUserRecord, - ClientCredentialsTokenResponse, - DeviceApprovalInput, - FetchLike, - MintUserAccessTokenInput, - MintUserAccessTokenResponse, - OidcDiscoveryDocument, - ParsedDeviceApprovalRedirect, - PmtHouseClientOptions, - TokenExchangeResponse, - UpsertAppUserInput, - UsageApiResponse, - UsageQueryInput, -} from "@/lib/pymthouse/types"; - -const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; -const SUBJECT_ACCESS_TOKEN_TYPE = - "urn:ietf:params:oauth:token-type:access_token"; -const DEVICE_RESOURCE_PREFIX = "urn:pmth:device_code:"; - -export class PmtHouseClient { - private readonly issuerUrl: string; - private readonly publicClientId: string; - private readonly m2mClientId: string; - private readonly m2mClientSecret: string; - private readonly fetchImpl: FetchLike; - private readonly logger?: PmtHouseClientOptions["logger"]; - - constructor(options: PmtHouseClientOptions) { - this.issuerUrl = options.issuerUrl.replace(/\/+$/, ""); - this.publicClientId = options.publicClientId; - this.m2mClientId = options.m2mClientId; - this.m2mClientSecret = options.m2mClientSecret; - this.fetchImpl = options.fetch ?? fetch; - this.logger = options.logger; - } - - async getDiscovery(): Promise { - return fetchDiscoveryDocument(this.issuerUrl, this.fetchImpl); - } - - verifyIssuer(iss: string): boolean { - const candidate = iss.trim().replace(/\/+$/, ""); - return candidate === this.issuerUrl; - } - - parseDeviceApprovalRedirect( - searchParams: URLSearchParams, - ): ParsedDeviceApprovalRedirect { - const issuer = searchParams.get("iss")?.trim() ?? ""; - const targetLinkUri = searchParams.get("target_link_uri")?.trim() ?? ""; - - if (!issuer || !targetLinkUri) { - throw new PmtHouseError("Missing iss or target_link_uri", { - status: 400, - code: "invalid_request", - }); - } - - if (!this.verifyIssuer(issuer)) { - throw new PmtHouseError("Issuer mismatch for initiate login", { - status: 400, - code: "invalid_issuer", - }); - } - - let targetUrl: URL; - try { - targetUrl = new URL(targetLinkUri); - } catch { - throw new PmtHouseError("target_link_uri is not a valid URL", { - status: 400, - code: "invalid_target", - }); - } - - const issuerOrigin = new URL(this.issuerUrl).origin; - if (targetUrl.origin !== issuerOrigin || targetUrl.pathname !== "/oidc/device") { - throw new PmtHouseError("target_link_uri does not point to the issuer device path", { - status: 400, - code: "invalid_target", - }); - } - - const userCode = this.normalizeUserCode( - targetUrl.searchParams.get("user_code") ?? "", - ); - const clientId = targetUrl.searchParams.get("client_id")?.trim() ?? ""; - - if (!userCode || !clientId) { - throw new PmtHouseError("target_link_uri is missing user_code or client_id", { - status: 400, - code: "invalid_target", - }); - } - - return { - issuer, - targetLinkUri, - userCode, - clientId, - }; - } - - async listAppUsers(): Promise<{ users: AppUserRecord[] }> { - const url = `${this.getAppsBaseUrl()}/users`; - return this.requestJson<{ users: AppUserRecord[] }>(url, { - method: "GET", - headers: this.builderHeaders(), - cache: "no-store", - }); - } - - async upsertAppUser(input: UpsertAppUserInput): Promise { - const payload: Record = { - externalUserId: input.externalUserId, - }; - if (input.email) payload.email = input.email; - if (input.status) payload.status = input.status; - - const url = `${this.getAppsBaseUrl()}/users`; - return this.requestJson(url, { - method: "POST", - headers: this.builderHeaders(), - body: JSON.stringify(payload), - cache: "no-store", - }); - } - - async deleteAppUser(params: { externalUserId: string }): Promise<{ success: boolean }> { - const url = new URL(`${this.getAppsBaseUrl()}/users`); - url.searchParams.set("externalUserId", params.externalUserId); - return this.requestJson<{ success: boolean }>(url.toString(), { - method: "DELETE", - headers: this.builderHeaders(), - cache: "no-store", - }); - } - - async mintUserAccessToken( - input: MintUserAccessTokenInput, - ): Promise { - const url = `${this.getAppsBaseUrl()}/users/${encodeURIComponent(input.externalUserId)}/token`; - const body = input.scope ? { scope: input.scope } : {}; - - return this.requestJson(url, { - method: "POST", - headers: this.builderHeaders(), - body: JSON.stringify(body), - cache: "no-store", - }); - } - - async completeDeviceApproval( - input: DeviceApprovalInput, - ): Promise { - const discovery = await this.getDiscovery(); - const form = new URLSearchParams(); - form.set("grant_type", TOKEN_EXCHANGE_GRANT); - form.set("subject_token", input.userJwt); - form.set("subject_token_type", SUBJECT_ACCESS_TOKEN_TYPE); - form.set( - "resource", - `${DEVICE_RESOURCE_PREFIX}${this.normalizeUserCode(input.userCode)}`, - ); - - return this.requestJson(discovery.token_endpoint, { - method: "POST", - headers: this.oidcFormHeaders(), - body: form.toString(), - cache: "no-store", - }); - } - - async issueMachineAccessToken( - scope = "sign:job", - ): Promise { - const discovery = await this.getDiscovery(); - const form = new URLSearchParams(); - form.set("grant_type", "client_credentials"); - form.set("scope", scope); - - return this.requestJson(discovery.token_endpoint, { - method: "POST", - headers: this.oidcFormHeaders(), - body: form.toString(), - cache: "no-store", - }); - } - - async exchangeForSignerSession(input: { - userJwt: string; - }): Promise { - const discovery = await this.getDiscovery(); - const form = new URLSearchParams(); - form.set("grant_type", TOKEN_EXCHANGE_GRANT); - form.set("subject_token", input.userJwt); - form.set("subject_token_type", SUBJECT_ACCESS_TOKEN_TYPE); - form.set("scope", "sign:job"); - - return this.requestJson(discovery.token_endpoint, { - method: "POST", - headers: this.oidcFormHeaders(), - body: form.toString(), - cache: "no-store", - }); - } - - async createSignerSessionToken(params: { - userJwt?: string; - }): Promise { - if (params.userJwt) { - try { - return await this.exchangeForSignerSession({ userJwt: params.userJwt }); - } catch (error) { - const err = this.asError(error); - this.logger?.warn?.("User JWT exchange failed, falling back to machine exchange", { - code: err.code, - status: err.status, - }); - } - } - - const machineToken = await this.issueMachineAccessToken("sign:job"); - if (!machineToken.access_token) { - throw new PmtHouseError("Client credentials flow did not return access_token", { - status: 502, - code: "invalid_token_response", - }); - } - - return this.exchangeForSignerSession({ userJwt: machineToken.access_token }); - } - - async getUsage(input: UsageQueryInput = {}): Promise { - const url = new URL(`${this.getAppsBaseUrl()}/usage`); - if (input.startDate) url.searchParams.set("startDate", input.startDate); - if (input.endDate) url.searchParams.set("endDate", input.endDate); - if (input.groupBy) url.searchParams.set("groupBy", input.groupBy); - if (input.userId) url.searchParams.set("userId", input.userId); - - return this.requestJson(url.toString(), { - method: "GET", - headers: this.builderHeaders(), - cache: "no-store", - }); - } - - private normalizeUserCode(value: string): string { - return value - .replace(/[a-z]/g, (char) => char.toUpperCase()) - .replace(/\W/g, ""); - } - - private getAppsBaseUrl(): string { - return `${this.getIssuerOrigin()}/api/v1/apps/${encodeURIComponent(this.publicClientId)}`; - } - - private getIssuerOrigin(): string { - return new URL(this.issuerUrl).origin; - } - - private builderHeaders(): HeadersInit { - return { - Authorization: this.basicAuthorizationHeader(), - "Content-Type": "application/json", - Accept: "application/json", - }; - } - - private oidcFormHeaders(): HeadersInit { - return { - Authorization: this.basicAuthorizationHeader(), - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }; - } - - private basicAuthorizationHeader(): string { - const raw = `${this.m2mClientId}:${this.m2mClientSecret}`; - return `Basic ${Buffer.from(raw).toString("base64")}`; - } - - private async requestJson( - url: string, - init: RequestInit, - ): Promise { - this.logger?.debug?.("PmtHouse request", { - method: init.method ?? "GET", - url, - }); - - const response = await this.fetchImpl(url, init); - const raw = await response.text(); - const parsed = raw ? this.safeParseJson(raw) : null; - - if (!response.ok) { - const details = (parsed ?? {}) as Record; - const description = - typeof details.error_description === "string" - ? details.error_description - : typeof details.error === "string" - ? details.error - : `Request failed (${response.status})`; - - throw new PmtHouseError(description, { - status: response.status, - code: - typeof details.error === "string" - ? details.error - : "pymthouse_http_error", - details, - }); - } - - if (!parsed) { - return {} as T; - } - - return parsed as T; - } - - private safeParseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } - } - - private asError(error: unknown): PmtHouseError { - if (error instanceof PmtHouseError) { - return error; - } - - if (error instanceof Error) { - return new PmtHouseError(error.message, { - code: "unexpected_error", - status: 500, - }); - } - - return new PmtHouseError("Unexpected error", { - code: "unexpected_error", - status: 500, - }); - } -} diff --git a/lib/pymthouse/discovery.ts b/lib/pymthouse/discovery.ts deleted file mode 100644 index 1f0cfea..0000000 --- a/lib/pymthouse/discovery.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { FetchLike, OidcDiscoveryDocument } from "@/lib/pymthouse/types"; -import { PmtHouseError } from "@/lib/pymthouse/errors"; - -const CACHE_TTL_MS = 5 * 60 * 1000; - -type CacheEntry = { - document: OidcDiscoveryDocument; - fetchedAt: number; -}; - -const discoveryCache = new Map(); - -export async function fetchDiscoveryDocument( - issuerUrl: string, - fetchImpl: FetchLike, -): Promise { - const normalizedIssuer = issuerUrl.replace(/\/+$/, ""); - const cached = discoveryCache.get(normalizedIssuer); - const now = Date.now(); - - if (cached && now - cached.fetchedAt < CACHE_TTL_MS) { - return cached.document; - } - - const discoveryUrl = - `${normalizedIssuer}/.well-known/openid-configuration`; - - const response = await fetchImpl(discoveryUrl, { - method: "GET", - headers: { - Accept: "application/json", - }, - cache: "no-store", - }); - - if (!response.ok) { - throw new PmtHouseError( - `Failed to load OIDC discovery (${response.status})`, - { - status: response.status, - code: "oidc_discovery_failed", - }, - ); - } - - const payload = (await response.json()) as Partial; - - if (!payload.issuer || !payload.token_endpoint || !payload.jwks_uri) { - throw new PmtHouseError("OIDC discovery document is missing fields", { - status: 500, - code: "oidc_discovery_invalid", - details: payload, - }); - } - - const document: OidcDiscoveryDocument = { - issuer: payload.issuer, - authorization_endpoint: payload.authorization_endpoint ?? "", - token_endpoint: payload.token_endpoint, - jwks_uri: payload.jwks_uri, - userinfo_endpoint: payload.userinfo_endpoint, - device_authorization_endpoint: payload.device_authorization_endpoint, - }; - - discoveryCache.set(normalizedIssuer, { document, fetchedAt: now }); - return document; -} - -export function clearDiscoveryCache(issuerUrl?: string): void { - if (!issuerUrl) { - discoveryCache.clear(); - return; - } - - discoveryCache.delete(issuerUrl.replace(/\/+$/, "")); -} diff --git a/lib/pymthouse/errors.ts b/lib/pymthouse/errors.ts deleted file mode 100644 index 7d3e6b5..0000000 --- a/lib/pymthouse/errors.ts +++ /dev/null @@ -1,45 +0,0 @@ -export class PmtHouseError extends Error { - readonly status: number; - readonly code: string; - readonly details?: unknown; - - constructor( - message: string, - { - status = 500, - code = "pymthouse_error", - details, - }: { - status?: number; - code?: string; - details?: unknown; - } = {}, - ) { - super(message); - this.name = "PmtHouseError"; - this.status = status; - this.code = code; - this.details = details; - } -} - -export function toPmtHouseError( - error: unknown, - fallbackMessage: string, -): PmtHouseError { - if (error instanceof PmtHouseError) { - return error; - } - - if (error instanceof Error) { - return new PmtHouseError(error.message || fallbackMessage, { - code: "unexpected_error", - status: 500, - }); - } - - return new PmtHouseError(fallbackMessage, { - code: "unexpected_error", - status: 500, - }); -} diff --git a/lib/pymthouse/format.ts b/lib/pymthouse/format.ts deleted file mode 100644 index efd0966..0000000 --- a/lib/pymthouse/format.ts +++ /dev/null @@ -1,24 +0,0 @@ -const WEI_PER_ETH = BigInt("1000000000000000000"); - -export function formatWeiToEth(wei: string): string { - const weiValue = BigInt(wei || "0"); - const whole = weiValue / WEI_PER_ETH; - const fraction = weiValue % WEI_PER_ETH; - const fractionStr = fraction.toString().padStart(18, "0").slice(0, 6); - if (fractionStr === "000000") { - return `${whole.toString()} ETH`; - } - return `${whole.toString()}.${fractionStr} ETH`; -} - -export function formatWeiToUsd( - wei: string, - usdPerEth = 3500, -): string { - const weiValue = BigInt(wei || "0"); - const whole = weiValue / WEI_PER_ETH; - const fraction = weiValue % WEI_PER_ETH; - const eth = Number(whole) + Number(fraction) / 1e18; - const usd = eth * usdPerEth; - return `$${usd.toFixed(2)}`; -} diff --git a/lib/pymthouse/index.ts b/lib/pymthouse/index.ts deleted file mode 100644 index 8d8bc12..0000000 --- a/lib/pymthouse/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { PmtHouseClient } from "@/lib/pymthouse/client"; -export { PmtHouseError, toPmtHouseError } from "@/lib/pymthouse/errors"; -export { formatWeiToEth, formatWeiToUsd } from "@/lib/pymthouse/format"; -export { getPmtHouseClient } from "@/lib/pymthouse/server"; -export type { - AppUserRecord, - ClientCredentialsTokenResponse, - DeviceApprovalInput, - MintUserAccessTokenInput, - MintUserAccessTokenResponse, - OidcDiscoveryDocument, - ParsedDeviceApprovalRedirect, - PmtHouseClientOptions, - TokenExchangeResponse, - UpsertAppUserInput, - UsageApiResponse, - UsageByUserRow, - UsageQueryInput, - UsageTotals, -} from "@/lib/pymthouse/types"; diff --git a/lib/pymthouse/server.ts b/lib/pymthouse/server.ts deleted file mode 100644 index fb7b0a5..0000000 --- a/lib/pymthouse/server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { PmtHouseClient } from "@/lib/pymthouse/client"; -import { PmtHouseError } from "@/lib/pymthouse/errors"; - -let cachedClient: PmtHouseClient | null = null; - -function requiredEnv(name: string): string { - const value = process.env[name]; - if (value && value.trim()) { - return value.trim(); - } - - throw new PmtHouseError(`Missing required environment variable: ${name}`, { - status: 500, - code: "missing_env", - }); -} - -export function getPmtHouseClient(): PmtHouseClient { - if (cachedClient) { - return cachedClient; - } - - cachedClient = new PmtHouseClient({ - issuerUrl: requiredEnv("PYMTHOUSE_ISSUER_URL"), - publicClientId: requiredEnv("PYMTHOUSE_PUBLIC_CLIENT_ID"), - m2mClientId: requiredEnv("PYMTHOUSE_M2M_CLIENT_ID"), - m2mClientSecret: requiredEnv("PYMTHOUSE_M2M_CLIENT_SECRET"), - logger: { - debug: (message, details) => { - if (process.env.NODE_ENV !== "production") { - console.debug(`[pymthouse] ${message}`, details ?? {}); - } - }, - warn: (message, details) => { - console.warn(`[pymthouse] ${message}`, details ?? {}); - }, - }, - }); - - return cachedClient; -} diff --git a/lib/pymthouse/types.ts b/lib/pymthouse/types.ts deleted file mode 100644 index 33d8ffd..0000000 --- a/lib/pymthouse/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -export type FetchLike = ( - input: string | URL | Request, - init?: RequestInit, -) => Promise; - -export interface OidcDiscoveryDocument { - issuer: string; - authorization_endpoint: string; - token_endpoint: string; - jwks_uri: string; - userinfo_endpoint?: string; - device_authorization_endpoint?: string; -} - -export interface PmtHouseClientOptions { - issuerUrl: string; - publicClientId: string; - m2mClientId: string; - m2mClientSecret: string; - fetch?: FetchLike; - logger?: { - debug?: (message: string, details?: Record) => void; - warn?: (message: string, details?: Record) => void; - }; -} - -export interface UpsertAppUserInput { - externalUserId: string; - email?: string; - status?: "active" | "inactive"; -} - -export interface AppUserRecord { - id: string; - clientId: string; - externalUserId: string; - email: string | null; - status: string; - role: string; - createdAt: string; -} - -export interface MintUserAccessTokenInput { - externalUserId: string; - scope?: string; -} - -export interface MintUserAccessTokenResponse { - access_token: string; - refresh_token: string; - token_type: "Bearer"; - expires_in: number; - scope: string; - subject_type: "app_user"; - correlation_id?: string; -} - -export interface DeviceApprovalInput { - userJwt: string; - userCode: string; -} - -export interface TokenExchangeResponse { - access_token: string; - token_type: "Bearer"; - expires_in: number; - scope: string; - issued_token_type: string; -} - -export interface UsageQueryInput { - startDate?: string; - endDate?: string; - groupBy?: "none" | "user"; - userId?: string; -} - -export interface UsageTotals { - requestCount: number; - totalFeeWei: string; -} - -export interface UsageByUserRow { - endUserId: string; - externalUserId: string | null; - requestCount: number; - feeWei: string; - userType?: "system_managed" | "oidc_authorized" | "unknown"; - identifier?: string; -} - -export interface UsageApiResponse { - clientId: string; - period: { - start: string | null; - end: string | null; - }; - totals: UsageTotals; - byUser?: UsageByUserRow[]; -} - -export interface ClientCredentialsTokenResponse { - access_token: string; - token_type: "Bearer"; - expires_in?: number; - scope?: string; - [key: string]: unknown; -} - -export interface ParsedDeviceApprovalRedirect { - issuer: string; - targetLinkUri: string; - userCode: string; - clientId: string; -} diff --git a/package.json b/package.json index dfae896..56fdfb0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format:check": "prettier . --check" }, "dependencies": { + "@pymthouse/builder-api": "^0.0.4", "framer-motion": "^11.15.0", "gray-matter": "^4.0.3", "jose": "^6.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be71e63..0bc8581 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@pymthouse/builder-api': + specifier: ^0.0.4 + version: 0.0.4 framer-motion: specifier: ^11.15.0 version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -403,6 +406,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@pymthouse/builder-api@0.0.4': + resolution: {integrity: sha512-24ZDRtIcs2QryJndQW8Y2ELlgAI3wCtSdHyv/d3jk+FBIUil9WozeX6gwOp6Od+pm7u4gT2OF/RqhMDUlnyYHw==} + engines: {node: '>=20'} + '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -1819,6 +1826,9 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2541,6 +2551,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@pymthouse/builder-api@0.0.4': + dependencies: + oauth4webapi: 3.8.5 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: '@standard-schema/spec': 1.1.0 @@ -4141,6 +4155,8 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + oauth4webapi@3.8.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} From 1726e2448f0ac38103f181e33e7aa54372d32069 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sun, 19 Apr 2026 23:38:18 -0400 Subject: [PATCH 4/5] feat: enhance login flow with device flow handling and loading states Refactored the login page to support device flow authentication, introducing a new `DeviceFlowLoadingScreen` component for improved user experience during device login. Updated the `LoginRoute` to conditionally render based on connection status and device flow parameters. Added loading indicators to enhance feedback during authentication processes. --- app/(studio-auth)/studio/login/page.tsx | 46 ++++++++++++++++--- components/studio/DeviceFlowLoadingScreen.tsx | 46 +++++++++++++++++++ components/studio/LoginPage.tsx | 5 ++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 components/studio/DeviceFlowLoadingScreen.tsx diff --git a/app/(studio-auth)/studio/login/page.tsx b/app/(studio-auth)/studio/login/page.tsx index 3d97e62..e06476c 100644 --- a/app/(studio-auth)/studio/login/page.tsx +++ b/app/(studio-auth)/studio/login/page.tsx @@ -1,21 +1,55 @@ "use client"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/components/studio/AuthContext"; import LoginPage from "@/components/studio/LoginPage"; -export default function LoginRoute() { +function LoginRouteInner() { const { isConnected, isLoading } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); + const deviceFlow = searchParams.get("flow") === "device"; useEffect(() => { - if (!isLoading && isConnected) { + if (!isLoading && isConnected && !deviceFlow) { router.replace("/studio"); } - }, [isConnected, isLoading, router]); + }, [isConnected, isLoading, deviceFlow, router]); + + if (isLoading) { + return ( +
+
+
+ ); + } - if (isLoading || isConnected) return null; + if (isConnected && !deviceFlow) { + return null; + } return ; } + +export default function LoginRoute() { + return ( + +
+
+ } + > + +
+ ); +} diff --git a/components/studio/DeviceFlowLoadingScreen.tsx b/components/studio/DeviceFlowLoadingScreen.tsx new file mode 100644 index 0000000..8170527 --- /dev/null +++ b/components/studio/DeviceFlowLoadingScreen.tsx @@ -0,0 +1,46 @@ +import { LivepeerSymbol } from "@/components/icons/LivepeerLogo"; + +/** + * Full-screen loading state for RFC 8628 / third-party device login — matches + * `/studio/device-approved` so users stay oriented (no dashboard flash). + */ +export default function DeviceFlowLoadingScreen() { + return ( +
+
+ ); +} diff --git a/components/studio/LoginPage.tsx b/components/studio/LoginPage.tsx index 9c9ddc1..10ada18 100644 --- a/components/studio/LoginPage.tsx +++ b/components/studio/LoginPage.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { useAuth, type AuthProvider } from "@/components/studio/AuthContext"; import { LivepeerSymbol } from "@/components/icons/LivepeerLogo"; +import DeviceFlowLoadingScreen from "@/components/studio/DeviceFlowLoadingScreen"; function GitHubIcon({ className }: { className?: string }) { return ( @@ -137,6 +138,10 @@ export default function LoginPage() { void submitLogin({ provider }); } + if (deviceFlow && isSubmitting) { + return ; + } + return (
{/* ─── Layer 1: dark base ─── */} From 0a77b58af941816f9af19222f782250f6f32b377 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 20 Apr 2026 21:12:34 -0400 Subject: [PATCH 5/5] feat: refactor UsageTab component to improve data handling and loading states Updated the UsageTab component to utilize a new `normalizeUsagePayload` function for better data management. Refactored state handling for usage data, ensuring default values are set correctly. Enhanced loading indicators and conditional rendering for improved user experience during data fetching. Removed unused mock data imports and streamlined the code for clarity. --- components/studio/settings/UsageTab.tsx | 110 +++++++++++++++--------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/components/studio/settings/UsageTab.tsx b/components/studio/settings/UsageTab.tsx index ab2946f..4591219 100644 --- a/components/studio/settings/UsageTab.tsx +++ b/components/studio/settings/UsageTab.tsx @@ -14,21 +14,43 @@ import { ChevronDown } from "lucide-react"; import StatCard from "@/components/studio/statistics/StatCard"; import PeriodToggle from "@/components/studio/statistics/PeriodToggle"; import { StackedChartTooltip } from "@/components/studio/statistics/ChartTooltip"; -import { - ACCOUNT_USAGE_SUMMARY, - ACCOUNT_USAGE_BY_SIGNER, - ACCOUNT_USAGE_BY_TOKEN, - ACCOUNT_USAGE_DAILY, - MOCK_RECENT_REQUESTS, - SIGNER_COLORS, -} from "@/lib/studio/mock-data"; +import { SIGNER_COLORS } from "@/lib/studio/mock-data"; import type { NetworkStat, AccountActivityRow, + AccountUsageBySigner, + AccountUsageByToken, AccountUsageDailyPoint, + AccountUsageSummary, SignerKey, } from "@/lib/studio/types"; +type UsagePayload = { + summary: AccountUsageSummary; + bySigner: AccountUsageBySigner[]; + byToken: AccountUsageByToken[]; + daily: AccountUsageDailyPoint[]; + recentRequests: AccountActivityRow[]; +}; + +function normalizeUsagePayload( + payload: Partial & { recentRequests?: AccountActivityRow[] }, +): UsagePayload { + return { + summary: payload.summary ?? { + requests: 0, + spendDisplay: "$0.00", + freeTierUsed: 0, + freeTierLimit: 10_000, + freeTierResetIn: "—", + }, + bySigner: payload.bySigner ?? [], + byToken: payload.byToken ?? [], + daily: payload.daily ?? [], + recentRequests: payload.recentRequests ?? [], + }; +} + // ─── Period filter ─── type Period = "24h" | "7d" | "30d" | "3m"; @@ -128,14 +150,9 @@ export default function UsageTab() { const [period, setPeriod] = useState("30d"); const [signerFilter, setSignerFilter] = useState("all"); const [tokenFilter, setTokenFilter] = useState("all"); - const [usageLoading, setUsageLoading] = useState(false); + const [usageLoading, setUsageLoading] = useState(true); const [usageError, setUsageError] = useState(null); - const [usageData, setUsageData] = useState<{ - summary: typeof ACCOUNT_USAGE_SUMMARY; - bySigner: typeof ACCOUNT_USAGE_BY_SIGNER; - byToken: typeof ACCOUNT_USAGE_BY_TOKEN; - daily: typeof ACCOUNT_USAGE_DAILY; - } | null>(null); + const [usageData, setUsageData] = useState(null); const [highlightedRequestId, setHighlightedRequestId] = useState< string | null >(null); @@ -152,23 +169,14 @@ export default function UsageTab() { method: "GET", cache: "no-store", }); - const payload = (await response.json().catch(() => ({}))) as { - summary?: typeof ACCOUNT_USAGE_SUMMARY; - bySigner?: typeof ACCOUNT_USAGE_BY_SIGNER; - byToken?: typeof ACCOUNT_USAGE_BY_TOKEN; - daily?: typeof ACCOUNT_USAGE_DAILY; + const payload = (await response.json().catch(() => ({}))) as Partial & { error_description?: string; }; if (!response.ok) { throw new Error(payload.error_description || "Failed to load usage data."); } if (!cancelled) { - setUsageData({ - summary: payload.summary ?? ACCOUNT_USAGE_SUMMARY, - bySigner: payload.bySigner ?? ACCOUNT_USAGE_BY_SIGNER, - byToken: payload.byToken ?? ACCOUNT_USAGE_BY_TOKEN, - daily: payload.daily ?? ACCOUNT_USAGE_DAILY, - }); + setUsageData(normalizeUsagePayload(payload)); } } catch (error) { if (!cancelled) { @@ -190,10 +198,10 @@ export default function UsageTab() { }; }, [period]); - const summary = usageData?.summary ?? ACCOUNT_USAGE_SUMMARY; - const signerRows = usageData?.bySigner ?? ACCOUNT_USAGE_BY_SIGNER; - const tokenRows = usageData?.byToken ?? ACCOUNT_USAGE_BY_TOKEN; - const dailyRows = usageData?.daily ?? ACCOUNT_USAGE_DAILY; + const summary = usageData?.summary; + const signerRows = usageData?.bySigner ?? []; + const tokenRows = usageData?.byToken ?? []; + const dailyRows = usageData?.daily ?? []; const chartData = useMemo( () => filterByPeriod(dailyRows, period), @@ -239,14 +247,15 @@ export default function UsageTab() { }, [period]); const filteredActivity = useMemo(() => { - return MOCK_RECENT_REQUESTS.filter((row) => { + const rows = usageData?.recentRequests ?? []; + return rows.filter((row) => { const ts = new Date(row.timestamp).getTime(); if (Number.isNaN(ts) || ts < periodCutoffMs) return false; if (signerFilter !== "all" && row.signer !== signerFilter) return false; if (tokenFilter !== "all" && row.tokenId !== tokenFilter) return false; return true; }); - }, [periodCutoffMs, signerFilter, tokenFilter]); + }, [usageData?.recentRequests, periodCutoffMs, signerFilter, tokenFilter]); const clearAllFilters = () => { setPeriod("30d"); @@ -287,23 +296,20 @@ export default function UsageTab() { }, [targetRequestId, filteredActivity]); const headerStats: NetworkStat[] = useMemo(() => { + if (!summary) return []; const freePct = Math.round( - (summary.freeTierUsed / - summary.freeTierLimit) * - 100, + (summary.freeTierUsed / summary.freeTierLimit) * 100, ); return [ { label: "Requests this period", value: summary.requests.toLocaleString(), - delta: "+12.4% vs last period", - trend: "up", + trend: "flat", }, { label: "Spend this period", value: summary.spendDisplay, - delta: "+8.1% vs last period", - trend: "up", + trend: "flat", }, { label: `Free tier (${freePct}% used)`, @@ -333,6 +339,8 @@ export default function UsageTab() { [tokenRows], ); + const showSyncBanner = usageLoading && usageData; + return (
{usageError && ( @@ -341,12 +349,32 @@ export default function UsageTab() {
)} - {usageLoading && ( + {usageLoading && !usageData && ( +
+

Loading usage…

+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+
+
+
+
+ )} + + {showSyncBanner && (
Syncing usage from pymthouse...
)} + {usageData && ( + <> {/* KPI cards */}
{headerStats.map((stat) => ( @@ -743,6 +771,8 @@ export default function UsageTab() {
)}
+ + )}
); }