diff --git a/docs/onboarding-state-machine.md b/docs/onboarding-state-machine.md new file mode 100644 index 0000000..4101e46 --- /dev/null +++ b/docs/onboarding-state-machine.md @@ -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 | diff --git a/src/app/api/auth/bootstrap/route.js b/src/app/api/auth/bootstrap/route.js new file mode 100644 index 0000000..9a27af9 --- /dev/null +++ b/src/app/api/auth/bootstrap/route.js @@ -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); + } + } + ); +} diff --git a/src/app/api/auth/onboarding/role/route.js b/src/app/api/auth/onboarding/role/route.js new file mode 100644 index 0000000..53001eb --- /dev/null +++ b/src/app/api/auth/onboarding/role/route.js @@ -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); + } +} diff --git a/src/lib/auth/identityService.js b/src/lib/auth/identityService.js new file mode 100644 index 0000000..6c6afbb --- /dev/null +++ b/src/lib/auth/identityService.js @@ -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"; +} diff --git a/src/lib/auth/roleProvisioning.js b/src/lib/auth/roleProvisioning.js new file mode 100644 index 0000000..1866010 --- /dev/null +++ b/src/lib/auth/roleProvisioning.js @@ -0,0 +1,172 @@ + +import { getDb } from "@/lib/mongodb"; +import { ObjectId } from "mongodb"; + +/** + * Atomic role provisioning with rollback support. + * Provisions a role and all associated records in a single transaction. + * + * @param {string} userId - The user's UUID + * @param {string} role - The role to provision (admin, grantee, payoutProvider) + * @param {object} roleData - Additional data for the role (e.g., grantee info, payout provider info) + * @returns {Promise<{success: boolean, result: object, error: string|null}>} + */ +export async function provisionRole(userId, role, roleData = {}) { + const db = await getDb(); + + // Start a session for transaction + const session = db.client.startSession(); + + try { + let result = null; + + await session.withTransaction(async () => { + const users = db.collection("users"); + + // 1. Find the user + const user = await users.findOne({ uuid: userId }, { session }); + if (!user) { + throw new Error(`User ${userId} not found`); + } + + // 2. Check if role is already provisioned + if (user.role === role) { + result = { alreadyProvisioned: true, user }; + return; + } + + // 3. Update user with role + const updateResult = await users.updateOne( + { uuid: userId }, + { + $set: { + role: role, + updatedAt: new Date(), + } + }, + { session } + ); + + if (updateResult.modifiedCount !== 1) { + throw new Error(`Failed to update user ${userId} with role ${role}`); + } + + // 4. Create role-specific records + switch (role) { + case "grantee": + await provisionGrantee(db, userId, roleData, session); + break; + case "payoutProvider": + await provisionPayoutProvider(db, userId, roleData, session); + break; + case "admin": + // Admin doesn't need additional records + break; + default: + throw new Error(`Unknown role: ${role}`); + } + + // 5. Update onboarding state + const onboarding = db.collection("onboarding"); + await onboarding.updateOne( + { userId }, + { + $set: { + state: "provisioned", + selectedRole: role, + completedAt: new Date(), + updatedAt: new Date(), + } + }, + { session } + ); + + // 6. Get the updated user + const updatedUser = await users.findOne({ uuid: userId }, { session }); + result = { success: true, user: updatedUser }; + }); + + return result; + } catch (error) { + return { success: false, error: error.message }; + } finally { + await session.endSession(); + } +} + +/** + * Provisions a grantee record. + */ +async function provisionGrantee(db, userId, roleData, session) { + const grantees = db.collection("grantees"); + + // Check if grantee already exists + const existing = await grantees.findOne({ userId }, { session }); + if (existing) return; + + const granteeDoc = { + userId, + organization: roleData.organization || null, + website: roleData.website || null, + description: roleData.description || null, + walletAddress: roleData.walletAddress || null, + verified: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await grantees.insertOne(granteeDoc, { session }); +} + +/** + * Provisions a payout provider record. + */ +async function provisionPayoutProvider(db, userId, roleData, session) { + const providers = db.collection("payoutProviders"); + + // Check if provider already exists + const existing = await providers.findOne({ userId }, { session }); + if (existing) return; + + const providerDoc = { + userId, + businessName: roleData.businessName || null, + payoutWallet: roleData.payoutWallet || null, + feeRate: roleData.feeRate || 0, + verified: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await providers.insertOne(providerDoc, { session }); +} + +/** + * Gets the current onboarding state for a user. + */ +export async function getOnboardingState(userId) { + const db = await getDb(); + const onboarding = db.collection("onboarding"); + return await onboarding.findOne({ userId }); +} + +/** + * Updates onboarding state with retry tracking. + */ +export async function updateOnboardingState(userId, update, retryOnConflict = true) { + const db = await getDb(); + const onboarding = db.collection("onboarding"); + + const result = await onboarding.updateOne( + { userId }, + { + $set: { + ...update, + updatedAt: new Date(), + }, + $inc: update.state === "failed" ? { retryCount: 1 } : {}, + } + ); + + return result; +} diff --git a/src/lib/db/schemas/onboarding.js b/src/lib/db/schemas/onboarding.js new file mode 100644 index 0000000..77d0702 --- /dev/null +++ b/src/lib/db/schemas/onboarding.js @@ -0,0 +1,91 @@ + +/** + * Onboarding State Schema + * Tracks the user's progress through the onboarding flow to ensure + * transactional consistency across OAuth callbacks and role selection. + * + * States: + * - pending: User initiated onboarding but hasn't completed + * - role_selected: User selected a role but provisioning pending + * - provisioned: Role successfully provisioned (final state) + * - failed: Onboarding failed (with reason) + * - disabled: User account is disabled + */ +export const OnboardingStateSchema = { + validator: { + $jsonSchema: { + bsonType: "object", + required: ["userId", "state", "createdAt", "updatedAt"], + properties: { + userId: { + bsonType: "string", + description: "Reference to the user's UUID", + }, + state: { + enum: ["pending", "role_selected", "provisioned", "failed", "disabled"], + description: "Current onboarding state", + }, + selectedRole: { + enum: ["admin", "grantee", "payoutProvider"], + description: "Role selected by the user during onboarding", + }, + provider: { + bsonType: "string", + description: "OAuth provider used (github, google, etc.)", + }, + providerUserId: { + bsonType: "string", + description: "User ID from the OAuth provider", + }, + providerEmail: { + bsonType: "string", + description: "Email from the OAuth provider", + }, + failedReason: { + bsonType: "string", + description: "Reason for failure if state is 'failed'", + }, + retryCount: { + bsonType: "int", + default: 0, + description: "Number of retry attempts", + }, + completedAt: { + bsonType: "date", + description: "Timestamp when onboarding completed", + }, + createdAt: { + bsonType: "date", + description: "Timestamp when onboarding started", + }, + updatedAt: { + bsonType: "date", + description: "Timestamp of last update", + }, + }, + }, + }, +}; + +/** + * Creates an onboarding state record for a new user. + */ +export function createOnboardingState(userId, provider = null, providerUserId = null) { + const now = new Date(); + return { + userId, + state: "pending", + provider, + providerUserId, + retryCount: 0, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Checks if an onboarding state is terminal (cannot be changed). + */ +export function isTerminalState(state) { + return ["provisioned", "disabled"].includes(state); +} diff --git a/test/integration/onboarding/role-provisioning.test.js b/test/integration/onboarding/role-provisioning.test.js new file mode 100644 index 0000000..2165b64 --- /dev/null +++ b/test/integration/onboarding/role-provisioning.test.js @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { createTestDb } from "../helpers/mongoFake.js"; +import { authCookieHeader } from "../helpers/cookies.js"; + +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-only-jwt-secret"; + +let db; +vi.mock("@/lib/mongodb", () => ({ + getDb: async () => db, + getMongoClientPromise: async () => db.client, +})); + +// Import the API handlers +const { POST: PostVerify } = await import("@/app/api/auth/verify/route.js"); +const { GET: GetBootstrap } = await import("@/app/api/auth/bootstrap/route.js"); +const { POST: PostRole } = await import("@/app/api/auth/onboarding/role/route.js"); + +// Helper to build signed transaction +function buildSignedTx(keypair, nonce) { + return { + toXDR: () => `test-xdr-for-${nonce}`, + }; +} + +describe("Onboarding: Role Provisioning", () => { + let keypair; + let walletAddress; + let authHeaders; + + beforeEach(async () => { + db = await createTestDb(); + keypair = Keypair.random(); + walletAddress = keypair.publicKey(); + + // Create a user first (mimic auth verification) + const nonce = "test-nonce-123"; + const tx = buildSignedTx(keypair, nonce); + + const verifyReq = new Request("http://localhost/api/auth/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + address: walletAddress, + nonce: nonce, + signedTransactionXdr: tx.toXDR(), + }), + }); + + const verifyRes = await PostVerify(verifyReq); + const setCookie = verifyRes.headers.getSetCookie(); + authHeaders = { + cookie: setCookie.join("; "), + }; + }); + + // Test 1: Successful role provisioning + it("should provision a role atomically", async () => { + const roleReq = new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ + role: "grantee", + roleData: { + organization: "Test Org", + description: "Test description", + }, + }), + }); + + const roleRes = await PostRole(roleReq); + const data = await roleRes.json(); + + expect(roleRes.status).toBe(200); + expect(data.success).toBe(true); + expect(data.user.role).toBe("grantee"); + expect(data.onboarding.state).toBe("provisioned"); + + // Verify onboarding state in DB + const onboarding = await db.collection("onboarding").findOne({ userId: data.user.id }); + expect(onboarding.state).toBe("provisioned"); + expect(onboarding.selectedRole).toBe("grantee"); + + // Verify user role in DB + const user = await db.collection("users").findOne({ uuid: data.user.id }); + expect(user.role).toBe("grantee"); + }); + + // Test 2: Cannot change role after provisioning + it("should prevent role changes after provisioning", async () => { + // First, provision a role + const roleReq = new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ role: "grantee" }), + }); + await PostRole(roleReq); + + // Try to change role + const changeReq = new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ role: "admin" }), + }); + + const changeRes = await PostRole(changeReq); + const data = await changeRes.json(); + + expect(changeRes.status).toBe(400); + expect(data.error).toContain("already provisioned"); + }); + + // Test 3: Disabled user cannot provision role + it("should prevent role provisioning for disabled users", async () => { + // Disable the user + await db.collection("users").updateOne( + { walletAddress: walletAddress }, + { $set: { status: "disabled" } } + ); + + const roleReq = new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ role: "grantee" }), + }); + + const roleRes = await PostRole(roleReq); + expect(roleRes.status).toBe(403); + }); + + // Test 4: Invalid role is rejected + it("should reject invalid roles", async () => { + const roleReq = new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ role: "invalid-role" }), + }); + + const roleRes = await PostRole(roleReq); + expect(roleRes.status).toBe(400); + }); + + // Test 5: Concurrent role selection (idempotency) + it("should handle concurrent role selection gracefully", async () => { + // Simulate concurrent requests + const requests = Array(3).fill().map(() => + new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ role: "grantee" }), + }) + ); + + const responses = await Promise.all(requests.map(req => PostRole(req))); + const results = await Promise.all(responses.map(res => res.json())); + + // All should succeed or only one succeeds and others get appropriate response + const successes = results.filter(r => r.success === true); + expect(successes.length).toBeGreaterThan(0); + + // Verify only one user exists + const users = await db.collection("users").find({ walletAddress: walletAddress }).toArray(); + expect(users.length).toBe(1); + }); + + // Test 6: Bootstrap endpoint returns correct state + it("should return correct onboarding state from bootstrap endpoint", async () => { + // First, provision a role + const roleReq = new Request("http://localhost/api/auth/onboarding/role", { + method: "POST", + headers: { + "content-type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ role: "payoutProvider" }), + }); + await PostRole(roleReq); + + // Call bootstrap + const bootstrapReq = new Request("http://localhost/api/auth/bootstrap", { + headers: authHeaders, + }); + const bootstrapRes = await GetBootstrap(bootstrapReq); + const data = await bootstrapRes.json(); + + expect(data.authenticated).toBe(true); + expect(data.user.role).toBe("payoutProvider"); + expect(data.onboarding.state).toBe("provisioned"); + expect(data.onboarding.selectedRole).toBe("payoutProvider"); + }); +});