Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d9710ca
fix: move steward passthrough after bearerToken declaration
0xSolace Apr 15, 2026
9cb5989
fix: dashboard layout accepts steward auth (cookie check)
0xSolace Apr 15, 2026
561b682
fix(auth): recognize steward sessions in dashboard chrome
0xSolace Apr 16, 2026
475f455
fix(auth): sweep generic ui flows for steward sessions
0xSolace Apr 16, 2026
389cbe1
fix(admin): remove stale wallets reference after useSessionAuth migra…
0xSolace Apr 16, 2026
742bdeb
ci: add workflow dispatch for PR approval
0xSolace Apr 16, 2026
07c37a5
fix(auth): make steward auth hook safe outside StewardProvider
0xSolace Apr 16, 2026
38512d5
fix(auth): stop AuthTokenSync from deleting cookies on initial mount
0xSolace Apr 16, 2026
c2aba66
fix(auth): stabilize auth prop to prevent StewardAuth recreation on e…
0xSolace Apr 16, 2026
3366bce
fix(auth): read steward session directly from localStorage
0xSolace Apr 16, 2026
1548c45
fix(auth): migrate chrome components to useSessionAuth
0xSolace Apr 16, 2026
c632dca
fix(auth): use server-side redirect flow for OAuth
0xSolace Apr 16, 2026
e684de1
fix(auth): switch CLI login page to steward session
0xSolace Apr 16, 2026
364405e
feat(auth): auto-refresh steward JWT before expiry
0xSolace Apr 16, 2026
3b6e2ac
feat(auth): display error states on login page for failed callbacks
0xSolace Apr 17, 2026
5f062ec
docs(auth): triage remaining Privy callsites for migration
0xSolace Apr 17, 2026
81be740
merge: Shaw's live AI pricing catalog + Sol's patches into production…
0xSolace Apr 17, 2026
85626a0
fix: implement Seedance 2.0 pricing parser and add models to catalog
0xSolace Apr 14, 2026
50f2ade
fix: document public-only blob access limitation
0xSolace Apr 14, 2026
1e7a428
fix: charge ~10% on failed video generation instead of full refund
0xSolace Apr 14, 2026
328b45a
fix: add error handling to /api/v1/pricing/summary endpoint
0xSolace Apr 14, 2026
2786a58
fix: add is_public column to generations, filter explore gallery
0xSolace Apr 14, 2026
f89fdd3
fix: evict expired entries from third-partyCatalogCache to prevent un…
0xSolace Apr 14, 2026
cdd65ff
fix: require authentication for image generation, remove anonymous fa…
0xSolace Apr 14, 2026
b1676b7
fix: add DB fallback when fal.ai HTML pricing parsers fail
0xSolace Apr 14, 2026
e4a77c6
fix: hoist quotedVideoCost declaration to handle catch block reference
0xSolace Apr 17, 2026
1defc42
fix: correct cache variable name in evictExpiredCacheEntries
0xSolace Apr 17, 2026
cf530df
chore: trigger fresh Vercel build (bust cache)
0xSolace Apr 17, 2026
c37625e
fix: actually replace third-partyCatalogCache with third-partyCatalog…
0xSolace Apr 17, 2026
3ad3b73
merge: resolve journal conflict with develop (keep 0065 + 0067)
0xSolace Apr 17, 2026
3712079
chore: remove approve-pr.yml workflow (security risk flagged in review)
0xSolace Apr 17, 2026
b9dba99
chore: address PR 458 review feedback
0xSolace Apr 17, 2026
8bbde6a
fix(auth): recover from expired access token instead of logging out
0xSolace Apr 17, 2026
17dcc95
fix(deps): bump @stwd/react to 0.6.6 for StewardAuthContext export
0xSolace Apr 17, 2026
fbaa834
fix(auth): try refreshSession on login page before showing UI
0xSolace Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/api/auth/steward-session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export async function POST(request: NextRequest) {
maxAge: 60 * 60 * 24 * 7, // 7 days
});

// Non-httpOnly flag so client JS can detect steward auth
cookieStore.set("steward-authed", "1", {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});

return NextResponse.json({ ok: true, userId: claims.userId });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
Expand All @@ -48,5 +57,6 @@ export async function POST(request: NextRequest) {
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete("steward-token");
cookieStore.delete("steward-authed");
return NextResponse.json({ ok: true });
}
76 changes: 27 additions & 49 deletions app/api/v1/generate-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { streamText } from "ai";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { requireAuthOrApiKey } from "@/lib/auth";
import { getAnonymousUser, getOrCreateAnonymousUser } from "@/lib/auth-anonymous";
import { uploadBase64Image } from "@/lib/blob";
import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit";
import { getProviderFromModel } from "@/lib/pricing";
Expand Down Expand Up @@ -94,46 +93,19 @@ interface AuthContext {
user: UserWithOrganization;
apiKey?: { id: string } | null;
session_token?: string;
isAnonymous: boolean;
}

/**
* Authenticate user - supports both authenticated and anonymous users
* Authenticate user - requires valid auth or API key.
* Anonymous users are not allowed to generate images.
*/
async function authenticateUser(req: NextRequest): Promise<AuthContext> {
// Try authenticated user first
try {
const authResult = await requireAuthOrApiKey(req);
return {
user: authResult.user,
apiKey: authResult.apiKey,
session_token: authResult.session_token,
isAnonymous: false,
};
} catch {
// Fall back to anonymous user
let anonData = await getAnonymousUser();

if (!anonData) {
const newAnonData = await getOrCreateAnonymousUser();
anonData = {
user: newAnonData.user,
session: newAnonData.session,
};
}

// Create a minimal UserWithOrganization for anonymous users
const anonymousUser: UserWithOrganization = {
...anonData.user,
organization_id: null,
organization: null,
};

return {
user: anonymousUser,
isAnonymous: true,
};
}
const authResult = await requireAuthOrApiKey(req);
return {
user: authResult.user,
apiKey: authResult.apiKey,
session_token: authResult.session_token,
};
}

/**
Expand All @@ -149,9 +121,17 @@ async function handlePOST(req: NextRequest) {
let imageServiceUnavailable = false;

try {
// Authenticate - supports both authenticated and anonymous users
const authContext = await authenticateUser(req);
const { user, apiKey, isAnonymous } = authContext;
// Authenticate - require valid credentials, no anonymous fallback
let authContext: AuthContext;
try {
authContext = await authenticateUser(req);
} catch {
return Response.json(
{ error: "Authentication required. Provide a valid session or API key." },
{ status: 401 },
);
}
const { user, apiKey } = authContext;

const requestBody = await req.json();
const {
Expand Down Expand Up @@ -200,7 +180,7 @@ async function handlePOST(req: NextRequest) {

// Reserve credits BEFORE generation to prevent TOCTOU race condition
let reservation: CreditReservation;
if (!isAnonymous && user.organization_id) {
if (user.organization_id) {
try {
reservation = await creditsService.reserve({
organizationId: user.organization_id,
Expand All @@ -221,11 +201,13 @@ async function handlePOST(req: NextRequest) {
throw error;
}
} else {
// Authenticated user without an organization - this shouldn't normally happen
// but handle gracefully by creating a no-op reservation
reservation = creditsService.createAnonymousReservation();
}

// Only create generation record for authenticated users with an organization
if (!isAnonymous && user.organization_id) {
// Create generation record for authenticated users with an organization
if (user.organization_id) {
const generation = await generationsService.create({
organization_id: user.organization_id,
user_id: user.id,
Expand Down Expand Up @@ -456,8 +438,7 @@ async function handlePOST(req: NextRequest) {
// Reconcile with 0 cost (full refund)
await reservation.reconcile(0);

// Only create usage record for authenticated users
if (!isAnonymous && user.organization_id) {
if (user.organization_id) {
const usageRecord = await usageService.create({
organization_id: user.organization_id,
user_id: user.id,
Expand Down Expand Up @@ -503,10 +484,9 @@ async function handlePOST(req: NextRequest) {
dimensions: imagePricingDimensions,
});

// Only create usage record for authenticated users
let usageRecordId: string | undefined;
let actualCostBilled = imageCost.totalCost;
if (!isAnonymous && user.organization_id) {
if (user.organization_id) {
const billing = await billFlatUsage(
{
organizationId: user.organization_id,
Expand Down Expand Up @@ -622,7 +602,6 @@ async function handlePOST(req: NextRequest) {
});

// Update generation record if we created one
// Note: generationId only exists if user.organization_id was present at creation time
if (generationId && usageRecordId) {
// For multi-image generations, create separate records for each image
// so they all appear in the gallery (which filters by storage_url)
Expand Down Expand Up @@ -700,9 +679,8 @@ async function handlePOST(req: NextRequest) {
}
}

// Log to Discord only for authenticated users with organization
// Log to Discord
if (
!isAnonymous &&
user.organization_id &&
user.organization &&
uploadResults.length > 0 &&
Expand Down
21 changes: 13 additions & 8 deletions app/api/v1/generate-video/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ async function handlePOST(request: NextRequest) {
let generationId: string | undefined;
let reservation: CreditReservation | undefined;
let selectedModel = "fal-ai/veo3";
let quotedVideoCost:
| Awaited<ReturnType<typeof calculateVideoGenerationCostFromCatalog>>
| undefined;
try {
const { user, apiKey } = await requireAuthOrApiKeyWithOrg(request);

Expand Down Expand Up @@ -76,7 +79,7 @@ async function handlePOST(request: NextRequest) {
}

const billingDefaults = getDefaultVideoBillingDimensions(model);
const quotedVideoCost = await calculateVideoGenerationCostFromCatalog({
quotedVideoCost = await calculateVideoGenerationCostFromCatalog({
model,
durationSeconds: billingDefaults.durationSeconds,
dimensions: billingDefaults.dimensions,
Expand Down Expand Up @@ -129,8 +132,8 @@ async function handlePOST(request: NextRequest) {

if (!data?.video?.url) {
logger.error("[VIDEO GENERATION] No video URL in response:", data);
// Reconcile with 0 cost (full refund)
await reservation.reconcile(0);
// Partial charge (~10% of quoted cost) — fal.ai may still bill for compute
await reservation.reconcile(Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6);
return NextResponse.json(
{ error: "No video URL was returned from the generation service" },
{ status: 500 },
Expand Down Expand Up @@ -162,8 +165,8 @@ async function handlePOST(request: NextRequest) {
blobFileSize = BigInt(uploadResult.size);
} catch (blobError) {
logger.error("[VIDEO GENERATION] Failed to upload to Vercel Blob:", blobError);
// Reconcile with 0 cost (full refund) - video generated but storage failed
await reservation.reconcile(0);
// Partial charge (~10% of quoted cost) — fal.ai already billed for the generation
await reservation.reconcile(Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6);
return NextResponse.json(
{ error: "Failed to store video in our storage. Please try again." },
{ status: 500 },
Expand Down Expand Up @@ -288,10 +291,12 @@ async function handlePOST(request: NextRequest) {

const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";

// If reservation was made, refund the reservation when generation fails
if (reservation) {
// If reservation was made, charge ~10% — fal.ai may still bill for the compute attempt
if (reservation && quotedVideoCost) {
await reservation.reconcile(Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6);
logger.info("[VIDEO GENERATION] Partial charge applied after failure (~10% of quoted cost)");
} else if (reservation) {
await reservation.reconcile(0);
logger.info("[VIDEO GENERATION] Credits refunded after failure");
}

try {
Expand Down
Loading
Loading