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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/onboarding-state-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Onboarding State Machine

## States

- **pending**: User created but onboarding not started
- **role_selected**: User selected a role
- **provisioned**: Role successfully provisioned (final state)
- **failed**: Provisioning failed
- **disabled**: Account disabled

## Transitions

| From | To | Trigger |
|------|-----|---------|
| pending | role_selected | User selects role |
| role_selected | provisioned | Provisioning success |
| role_selected | failed | Provisioning failure |
| any | disabled | Admin action |
| failed | pending | Retry |
79 changes: 79 additions & 0 deletions src/app/api/auth/bootstrap/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@

import { NextResponse } from "next/server";
import { withApiHardening } from "@/lib/api/hardening";
import { errorResponse } from "@/lib/api/errorResponse";
import { validateAuth } from "@/lib/auth/session";
import { getOnboardingState } from "@/lib/auth/roleProvisioning";
import { isIdentityDisabled } from "@/lib/auth/identityService";

export const dynamic = "force-dynamic";

/**
* Bootstrap endpoint - single source of truth for client initialization.
* Replaces scattered client-side checks with a server-authoritative endpoint.
*
* Returns:
* - Auth status (authenticated/not)
* - User info if authenticated
* - Onboarding state if onboarding in progress
* - Role if provisioned
* - Disabled status if account is disabled
*/
export async function GET(request) {
return withApiHardening(
request,
{ route: "auth-bootstrap", rateLimit: { limit: 30, windowMs: 60_000 } },
async () => {
try {
// 1. Validate auth
const auth = await validateAuth(request);
if (!auth.valid) {
return NextResponse.json({
authenticated: false,
reason: auth.reason || "not_authenticated",
});
}

// 2. Check if identity is disabled
const disabled = await isIdentityDisabled(auth.address);
if (disabled) {
return NextResponse.json({
authenticated: true,
disabled: true,
reason: "account_disabled",
});
}

// 3. Get onboarding state
const onboarding = await getOnboardingState(auth.address);

// 4. Build response
const response = {
authenticated: true,
user: {
id: auth.payload.sub,
walletAddress: auth.address,
email: auth.payload.email || null,
name: auth.payload.name || null,
},
};

if (onboarding) {
response.onboarding = {
state: onboarding.state,
selectedRole: onboarding.selectedRole || null,
provider: onboarding.provider || null,
};

if (onboarding.state === "provisioned") {
response.user.role = onboarding.selectedRole;
}
}

return NextResponse.json(response);
} catch (error) {
return errorResponse("Bootstrap error", 500);
}
}
);
}
100 changes: 100 additions & 0 deletions src/app/api/auth/onboarding/role/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
import { validateAuth } from "@/lib/auth/session";
import { provisionRole, getOnboardingState } from "@/lib/auth/roleProvisioning";
import { isIdentityDisabled } from "@/lib/auth/identityService";
import { errorResponse } from "@/lib/api/errorResponse";

export const dynamic = "force-dynamic";

/**
* POST /api/auth/onboarding/role
*
* Select and provision a role during onboarding.
* This is called after the user selects their role (admin, grantee, payoutProvider).
*
* Request body:
* {
* "role": "grantee" | "admin" | "payoutProvider",
* "roleData": { ... } // Optional role-specific data
* }
*
* Response:
* {
* "success": true,
* "user": { ... },
* "onboarding": { ... }
* }
*/
export async function POST(request) {
try {
// 1. Validate authentication
const auth = await validateAuth(request);
if (!auth.valid) {
return errorResponse("Unauthorized", 401);
}

// 2. Check if identity is disabled
const disabled = await isIdentityDisabled(auth.address);
if (disabled) {
return errorResponse("Account is disabled", 403);
}

// 3. Parse request body
const body = await request.json();
const { role, roleData = {} } = body;

// 4. Validate role
const validRoles = ["admin", "grantee", "payoutProvider"];
if (!role || !validRoles.includes(role)) {
return errorResponse(`Invalid role. Must be one of: ${validRoles.join(", ")}`, 400);
}

// 5. Check current onboarding state
const currentState = await getOnboardingState(auth.address);

if (currentState) {
// If already provisioned, prevent role change
if (currentState.state === "provisioned") {
return errorResponse("Role already provisioned. Cannot change role.", 400);
}

// If already failed, allow retry
if (currentState.state === "failed") {
// Allow retry - proceed with provisioning
}

// If role_selected, allow update if not provisioned
if (currentState.state === "role_selected" && currentState.selectedRole === role) {
// Same role selected again - still proceed
}
}

// 6. Provision the role atomically
const result = await provisionRole(auth.address, role, roleData);

if (!result.success) {
return errorResponse(result.error || "Failed to provision role", 500);
}

// 7. Return success response
return NextResponse.json({
success: true,
user: {
id: result.user.uuid,
walletAddress: result.user.walletAddress,
email: result.user.email || null,
name: result.user.displayName || null,
role: result.user.role,
},
onboarding: {
state: "provisioned",
selectedRole: result.user.role,
completedAt: new Date().toISOString(),
},
});

} catch (error) {
console.error("Role selection error:", error);
return errorResponse("An unexpected error occurred", 500);
}
}
132 changes: 132 additions & 0 deletions src/lib/auth/identityService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@

import { getDb } from "@/lib/mongodb";
import { randomUUID } from "crypto";

/**
* Creates or retrieves a user identity idempotently.
* This ensures that replaying OAuth callbacks or opening multiple tabs
* creates exactly one local identity.
*
* @param {string} walletAddress - The wallet address
* @param {object} providerInfo - OAuth provider info {provider, providerUserId, email, name}
* @returns {Promise<{user: object, isNew: boolean}>}
*/
export async function getOrCreateIdentity(walletAddress, providerInfo = null) {
const db = await getDb();
const users = db.collection("users");

const normalizedAddress = walletAddress.toLowerCase();

// 1. Try to find existing user
let user = await users.findOne({
$or: [
{ walletAddress: walletAddress },
{ walletAddressLower: normalizedAddress },
],
});

// 2. If user exists, link new provider if not already linked
if (user) {
if (providerInfo) {
// Check if provider is already linked
const providerKey = `linkedProviders.${providerInfo.provider}`;
if (!user.linkedProviders || !user.linkedProviders[providerInfo.provider]) {
await users.updateOne(
{ uuid: user.uuid },
{
$set: {
[`linkedProviders.${providerInfo.provider}`]: {
providerUserId: providerInfo.providerUserId,
email: providerInfo.email,
name: providerInfo.name,
linkedAt: new Date(),
},
updatedAt: new Date(),
},
}
);
// Re-fetch updated user
user = await users.findOne({ uuid: user.uuid });
}
}
return { user, isNew: false };
}

// 3. Check if there's a user with this email from another provider
if (providerInfo?.email) {
const existingByEmail = await users.findOne({
email: providerInfo.email,
});

if (existingByEmail) {
// Link this wallet to the existing account
await users.updateOne(
{ uuid: existingByEmail.uuid },
{
$set: {
walletAddress: walletAddress,
walletAddressLower: normalizedAddress,
updatedAt: new Date(),
[`linkedProviders.${providerInfo.provider}`]: {
providerUserId: providerInfo.providerUserId,
email: providerInfo.email,
name: providerInfo.name,
linkedAt: new Date(),
},
},
}
);
const updatedUser = await users.findOne({ uuid: existingByEmail.uuid });
return { user: updatedUser, isNew: false };
}
}

// 4. Create new user
const now = new Date();
const uuid = randomUUID();

const newUser = {
uuid,
walletAddress: walletAddress,
walletAddressLower: normalizedAddress,
email: providerInfo?.email || null,
displayName: providerInfo?.name || null,
createdAt: now,
updatedAt: now,
linkedProviders: providerInfo ? {
[providerInfo.provider]: {
providerUserId: providerInfo.providerUserId,
email: providerInfo.email,
name: providerInfo.name,
linkedAt: now,
}
} : {},
};

await users.insertOne(newUser);

// 5. Create onboarding state
const onboarding = db.collection("onboarding");
await onboarding.insertOne({
userId: uuid,
state: "pending",
provider: providerInfo?.provider || null,
providerUserId: providerInfo?.providerUserId || null,
providerEmail: providerInfo?.email || null,
retryCount: 0,
createdAt: now,
updatedAt: now,
});

return { user: newUser, isNew: true };
}

/**
* Checks if an identity is disabled.
*/
export async function isIdentityDisabled(userId) {
const db = await getDb();
const users = db.collection("users");
const user = await users.findOne({ uuid: userId });
return user?.status === "disabled";
}
Loading