/**
 * Auth API routes
 *
 * Login, logout, session verification, and auth status endpoints.
 */
import { createHmac, timingSafeEqual } from 'node:crypto';
import { isWorkspaceOwner } from '../../services/auth-service.js';
import { Workspace } from '../../core/workspace.js';
import { ensurePersonalFolder, archivePersonalFolder } from '../../services/personal-folder.js';
import { ensureWorkspaceAssetsFolder } from '../../services/workspace-assets-folder.js';
import { authUserToGitUser, ensurePersonForUser, findPersonRefForUser, linkChosenPersonToUser, BridgeError } from '../../services/user-person-bridge.js';
import * as access from '../../services/access-control.js';
import { safeReturnTo } from './oauth-api.js';
// ── Login rate limiting ──
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map();
/** Check if an email is currently locked out. Returns seconds remaining, or 0 if not locked. */
export function checkRateLimit(email) {
    const key = email.toLowerCase();
    const attempt = loginAttempts.get(key);
    if (!attempt || attempt.count < MAX_ATTEMPTS)
        return 0;
    const elapsed = Date.now() - attempt.firstAttempt;
    if (elapsed > LOCKOUT_MS) {
        loginAttempts.delete(key);
        return 0;
    }
    return Math.ceil((LOCKOUT_MS - elapsed) / 1000);
}
/** Record a failed login attempt. */
export function recordFailedAttempt(email) {
    const key = email.toLowerCase();
    const attempt = loginAttempts.get(key);
    if (!attempt || Date.now() - attempt.firstAttempt > LOCKOUT_MS) {
        loginAttempts.set(key, { count: 1, firstAttempt: Date.now() });
    }
    else {
        attempt.count++;
    }
}
/** Clear failed attempts on successful login. */
export function clearAttempts(email) {
    loginAttempts.delete(email.toLowerCase());
}
function requireOwner(req, authService) {
    const token = req.cookies?.['__sg_token'];
    if (!token)
        return null;
    const user = authService.verifyToken(token);
    if (!user || !isWorkspaceOwner(user.role))
        return null;
    return user;
}
/**
 * Resolve the caller to an AuthUser. The global auth hook skips /api/auth/*
 * so endpoints that need identity (like the token CRUD routes below) must
 * look up credentials themselves.
 *
 * Accepted forms:
 *   - Cookie `__sg_token=<jwt>` (web UI)
 *   - `Authorization: Bearer <jwt>` (CLI with stored JWT)
 *   - `Authorization: Bearer <sg_…>` (CLI with API token)
 *
 * Mirrors the precedence the main API middleware uses elsewhere
 * (src/server/index.ts) — needed so CLI commands like
 * `studiograph admin transfer-ownership` can call /api/auth/* endpoints
 * using the same Bearer sg_ token they use everywhere else.
 */
function requireUser(req, authService) {
    const cookieToken = req.cookies?.['__sg_token'];
    if (cookieToken) {
        const user = authService.verifyToken(cookieToken);
        if (user)
            return user;
    }
    const authHeader = req.headers?.authorization;
    if (authHeader?.startsWith('Bearer ')) {
        const bearer = authHeader.slice('Bearer '.length).trim();
        if (bearer.startsWith('sg_')) {
            return authService.verifyApiToken(bearer);
        }
        // Treat anything else as a JWT — verifyToken returns null on invalid input.
        return authService.verifyToken(bearer);
    }
    return null;
}
function managedCloudAuthConfig() {
    return {
        enabled: parseBoolean(process.env.STUDIOGRAPH_MANAGED_AUTH),
        cloudUrl: process.env.STUDIOGRAPH_CLOUD_URL?.trim() ?? '',
        orgId: process.env.STUDIOGRAPH_CLOUD_ORG_ID?.trim() ?? '',
        workspaceId: process.env.STUDIOGRAPH_CLOUD_WORKSPACE_ID?.trim() ?? '',
        token: process.env.STUDIOGRAPH_CLOUD_TOKEN?.trim() ?? '',
    };
}
function managedCloudLoginUrl(config) {
    if (!config.cloudUrl || !config.orgId || !config.workspaceId)
        return null;
    const url = new URL('/auth/workspace', config.cloudUrl);
    url.searchParams.set('orgId', config.orgId);
    url.searchParams.set('workspaceId', config.workspaceId);
    return url.toString();
}
function managedCloudLogoutUrl(config, returnTo) {
    if (!config.cloudUrl)
        return null;
    const url = new URL('/auth/logout', config.cloudUrl);
    if (returnTo)
        url.searchParams.set('returnTo', returnTo);
    return url.toString();
}
function requestOrigin(req) {
    const rawHost = req.headers?.['x-forwarded-host'] ?? req.headers?.host;
    const host = Array.isArray(rawHost) ? rawHost[0] : rawHost;
    if (!host)
        return null;
    const rawProto = req.headers?.['x-forwarded-proto'];
    const forwardedProto = Array.isArray(rawProto) ? rawProto[0] : rawProto;
    const proto = typeof forwardedProto === 'string' && forwardedProto.trim()
        ? forwardedProto.split(',')[0].trim()
        : 'http';
    return `${proto}://${host}`;
}
function managedLogoutReturnTo(req) {
    const origin = requestOrigin(req);
    if (!origin)
        return null;
    const url = new URL('/login', origin);
    url.searchParams.set('managedLogout', '1');
    return url.toString();
}
function verifyCloudLoginToken(token, secret) {
    const [payloadPart, signaturePart] = token.split('.');
    if (!payloadPart || !signaturePart || token.split('.').length !== 2) {
        throw new Error('Malformed login token');
    }
    const expected = hmacBase64Url(payloadPart, secret);
    if (!constantTimeEqual(signaturePart, expected)) {
        throw new Error('Invalid login token signature');
    }
    const claims = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
    if (claims.iss !== 'studiograph-cloud' || claims.aud !== 'studiograph-runtime') {
        throw new Error('Invalid login token audience');
    }
    if (!claims.orgId || !claims.workspaceId || !claims.userId || !claims.email || !claims.displayName || !claims.role) {
        throw new Error('Incomplete login token');
    }
    if (!['owner', 'admin', 'member'].includes(claims.role)) {
        throw new Error('Invalid login token role');
    }
    const now = Math.floor(Date.now() / 1000);
    if (typeof claims.exp !== 'number' || claims.exp < now) {
        throw new Error('Login token has expired');
    }
    if (typeof claims.iat !== 'number' || claims.iat > now + 60) {
        throw new Error('Login token was issued in the future');
    }
    return claims;
}
function runtimeRoleForCloudRole(cloudRole, existing, authService) {
    if (existing?.role === 'owner')
        return 'owner';
    if (cloudRole === 'owner')
        return 'owner';
    if (!authService.getOwner() && cloudRole === 'admin')
        return 'owner';
    return 'member';
}
function grantManagedWorkspaceAccess(authService, workspaceManager, user, cloudRole) {
    const folderRole = cloudRole === 'owner' || cloudRole === 'admin' ? 'admin' : 'editor';
    for (const repo of workspaceManager.getAllRepoConfigs()) {
        if (repo.personal)
            continue;
        const currentRole = authService.getFolderRole(user.id, repo.name);
        if (currentRole === 'admin')
            continue;
        if (currentRole !== folderRole)
            authService.grantFolderAccess(user.id, repo.name, folderRole);
    }
}
function hmacBase64Url(value, secret) {
    return createHmac('sha256', secret).update(value).digest('base64url');
}
function constantTimeEqual(a, b) {
    const left = Buffer.from(a);
    const right = Buffer.from(b);
    return left.length === right.length && timingSafeEqual(left, right);
}
function parseBoolean(value) {
    if (!value)
        return false;
    return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
export async function registerAuthApiRoutes(fastify, authService, workspacePath, workspaceManager, workspaceName) {
    fastify.get('/api/cloud/auth/callback', async (req, reply) => {
        const config = managedCloudAuthConfig();
        if (!config.enabled)
            return reply.status(404).send({ error: 'Managed cloud auth is not enabled' });
        if (!config.token || !config.workspaceId) {
            return reply.status(503).send({ error: 'Managed cloud auth is not configured' });
        }
        const { token } = req.query;
        if (!token)
            return reply.status(400).send({ error: 'Missing login token' });
        try {
            const claims = verifyCloudLoginToken(token, config.token);
            if (claims.workspaceId !== config.workspaceId) {
                return reply.status(403).send({ error: 'Login token was issued for a different workspace' });
            }
            if (config.orgId && claims.orgId !== config.orgId) {
                return reply.status(403).send({ error: 'Login token was issued for a different organization' });
            }
            const existing = authService.findUserByEmail(claims.email);
            const runtimeRole = runtimeRoleForCloudRole(claims.role, existing, authService);
            const user = authService.upsertCloudManagedUser({
                email: claims.email,
                displayName: claims.displayName,
                role: runtimeRole,
            });
            await ensurePersonalFolder(new Workspace(workspacePath), authService, user);
            grantManagedWorkspaceAccess(authService, workspaceManager, user, claims.role);
            workspaceManager.reloadConfig();
            // No person auto-provisioning here: task assignment + "My Tasks" key on the
            // account identity (`[[user:<id>]]`), so a person entity is optional and
            // never a precondition. Users add a profile explicitly from Settings → Profile.
            const secure = req.headers['x-forwarded-proto'] === 'https';
            reply.setCookie('__sg_token', authService.createSessionToken(user), {
                path: '/',
                httpOnly: true,
                sameSite: 'lax',
                secure,
                maxAge: 7 * 24 * 60 * 60,
            });
            // Honor a returnTo echoed back through the cloud round-trip (used by the
            // OAuth authorize → /login bounce so consent resumes after cloud auth).
            // safeReturnTo enforces same-origin: root-relative path only, rejecting
            // absolute/protocol-relative URLs, backslash-normalized off-origin, and
            // control chars.
            const returnTo = safeReturnTo(req.query.returnTo);
            return reply.redirect(returnTo);
        }
        catch (err) {
            return reply.status(401).send({ error: err?.message || 'Invalid login token' });
        }
    });
    // ── Admin user management ──
    // GET /api/auth/users — list all users. Any authenticated caller: folder
    // admins need this to populate the share-dialog "Add members" candidates,
    // and the comment @-mention surface needs the same shape.
    // Mutating endpoints below (POST/DELETE/PATCH) stay owner-only.
    fastify.get('/api/auth/users', async (req, reply) => {
        const user = requireUser(req, authService);
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        return reply.send({ users: authService.listUsers() });
    });
    // POST /api/auth/users — create a new user (admin only)
    fastify.post('/api/auth/users', async (req, reply) => {
        const admin = requireOwner(req, authService);
        if (!admin)
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const { email, password, displayName, role } = req.body;
        if (!email || !password) {
            return reply.status(400).send({ error: 'Email and password are required' });
        }
        if (role && role !== 'owner' && role !== 'member') {
            return reply.status(400).send({ error: `Invalid role "${role}" — must be "owner" or "member"` });
        }
        try {
            const user = authService.createUser(email, password, displayName || email.split('@')[0], role || 'member');
            await ensurePersonalFolder(new Workspace(workspacePath), authService, user);
            workspaceManager.reloadConfig();
            // No person auto-provisioning: assignment + "My Tasks" key on the account
            // identity (`[[user:<id>]]`), so a person entity is optional. Users add a
            // profile to the graph explicitly from Settings → Profile.
            return reply.send({ user });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // DELETE /api/auth/users/:id — delete a user (admin only, cannot delete self or last admin)
    fastify.delete('/api/auth/users/:id', async (req, reply) => {
        const admin = requireOwner(req, authService);
        if (!admin)
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const userId = parseInt(req.params.id, 10);
        if (isNaN(userId))
            return reply.status(400).send({ error: 'Invalid user ID' });
        if (admin.id === userId)
            return reply.status(400).send({ error: 'Cannot delete yourself' });
        const users = authService.listUsers();
        const target = users.find(u => u.id === userId);
        if (!target)
            return reply.status(404).send({ error: 'User not found' });
        if (target.role === 'owner' && users.filter(u => u.role === 'owner').length <= 1) {
            return reply.status(400).send({ error: 'Cannot delete the last workspace owner. Transfer ownership first.' });
        }
        // Transfer folders the target solely administers to the actor
        // (the workspace owner performing this deletion) BEFORE the cascade
        // delete — otherwise folder_access rows for those folders disappear
        // and the folder is left with no admins.
        //
        // Personal folders (the target's "My Folder") are a sub-case: they
        // transfer the same way, then get demoted to normal archive folders
        // so the one-personal-folder-per-user invariant holds and the actor
        // ends up with a plainly-named folder they own outright.
        const workspace = new Workspace(workspacePath);
        const preConfig = await workspace.loadConfig();
        const targetPersonalFolders = preConfig.repos.filter(r => r.personal === true && r.owner_id === target.id);
        const personalEntryCounts = new Map();
        for (const p of targetPersonalFolders) {
            try {
                personalEntryCounts.set(p.name, workspaceManager.getGraph(p.name).listEntityIds().length);
            }
            catch {
                personalEntryCounts.set(p.name, 0);
            }
        }
        const transferred = authService.transferFoldersOnDelete(target.id, admin.id);
        // Capture the old name upfront: archivePersonalFolder mutates the
        // RepoConfig in place (name/path change), so reading `p.name` after
        // the await would return the new archive name.
        const archived = [];
        for (const p of targetPersonalFolders) {
            const oldName = p.name;
            if (!transferred.includes(oldName))
                continue;
            const { newName, newDisplayName } = await archivePersonalFolder(workspace, oldName, target.displayName, authService);
            archived.push({
                oldName,
                newName,
                newDisplayName,
                entryCount: personalEntryCounts.get(oldName) ?? 0,
            });
        }
        authService.deleteUser(target.email);
        if (archived.length > 0)
            workspaceManager.reloadConfig();
        return reply.send({ ok: true, transferred, archived });
    });
    // GET /api/auth/users/:id/deletion-preview — show the actor what
    // will transfer if they delete this user. Owner-gated. Returns entry
    // counts and archive names so the confirmation UI can set expectations
    // WITHOUT exposing the contents of the target's private folder. The
    // preview is advisory; the DELETE endpoint is the source of truth.
    fastify.get('/api/auth/users/:id/deletion-preview', async (req, reply) => {
        const admin = requireOwner(req, authService);
        if (!admin)
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const userId = parseInt(req.params.id, 10);
        if (isNaN(userId))
            return reply.status(400).send({ error: 'Invalid user ID' });
        if (admin.id === userId)
            return reply.status(400).send({ error: 'Cannot preview deleting yourself' });
        const target = authService.listUsers().find(u => u.id === userId);
        if (!target)
            return reply.status(404).send({ error: 'User not found' });
        const workspace = new Workspace(workspacePath);
        const config = await workspace.loadConfig();
        // Mirror transferFoldersOnDelete's sole-admin rule so the preview
        // matches the real delete. Personal folders are 1-member by nature,
        // so they always qualify.
        const targetAdminFolders = authService.getUserFolders(target.id)
            .filter(f => authService.getFolderRole(target.id, f) === 'admin');
        const soleAdminFolders = targetAdminFolders.filter(f => authService.getFolderAdmins(f).length === 1);
        const date = new Date().toISOString().slice(0, 10);
        const personalFolders = config.repos.filter(r => r.personal === true && r.owner_id === target.id && soleAdminFolders.includes(r.name));
        const sharedSoleAdmin = soleAdminFolders.filter(f => !personalFolders.some(p => p.name === f));
        const personal = personalFolders.map(p => {
            let entryCount = 0;
            try {
                entryCount = workspaceManager.getGraph(p.name).listEntityIds().length;
            }
            catch { /* skip */ }
            return {
                entryCount,
                newDisplayName: `Archive: ${target.displayName} (${date})`,
            };
        });
        const shared = sharedSoleAdmin.map(f => {
            const repo = config.repos.find(r => r.name === f);
            let entryCount = 0;
            try {
                entryCount = workspaceManager.getGraph(f).listEntityIds().length;
            }
            catch { /* skip */ }
            return {
                name: f,
                displayName: repo?.display_name ?? f,
                entryCount,
            };
        });
        return reply.send({ personal, shared });
    });
    // PATCH /api/auth/users/:id/password — reset a user's password (admin only)
    fastify.patch('/api/auth/users/:id/password', async (req, reply) => {
        const admin = requireOwner(req, authService);
        if (!admin)
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const userId = parseInt(req.params.id, 10);
        if (isNaN(userId))
            return reply.status(400).send({ error: 'Invalid user ID' });
        const { password } = req.body;
        if (!password)
            return reply.status(400).send({ error: 'Password is required' });
        const users = authService.listUsers();
        const target = users.find(u => u.id === userId);
        if (!target)
            return reply.status(404).send({ error: 'User not found' });
        try {
            authService.updatePassword(target.email, password);
            return reply.send({ ok: true });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // PATCH /api/auth/users/:id — update a member's profile (owner only).
    // Currently accepts `email` and `displayName`; mirrors the existing
    // password-reset pattern (owner-gated, no user notification).
    fastify.patch('/api/auth/users/:id', async (req, reply) => {
        const admin = requireOwner(req, authService);
        if (!admin)
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const userId = parseInt(req.params.id, 10);
        if (isNaN(userId))
            return reply.status(400).send({ error: 'Invalid user ID' });
        const { email, displayName } = req.body;
        if (email === undefined && displayName === undefined) {
            return reply.status(400).send({ error: 'Nothing to update' });
        }
        if (email !== undefined) {
            const normalized = email.toLowerCase().trim();
            if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
                return reply.status(400).send({ error: 'Invalid email address' });
            }
        }
        const target = authService.findUserById(userId);
        if (!target)
            return reply.status(404).send({ error: 'User not found' });
        try {
            const updated = authService.updateProfile(userId, { email, displayName });
            return reply.send({ user: updated });
        }
        catch (err) {
            // SQLite raises a UNIQUE constraint error on duplicate email.
            if (String(err?.message ?? '').includes('UNIQUE')) {
                return reply.status(409).send({ error: 'That email is already in use' });
            }
            return reply.status(400).send({ error: err.message });
        }
    });
    // ── Per-user API tokens (used by MCP clients, CLI, and git HTTP) ──
    // POST /api/auth/tokens — mint a new token for the authenticated user.
    // Body: { name: string }. Returns the raw token once; only its hash is stored.
    fastify.post('/api/auth/tokens', async (req, reply) => {
        const user = requireUser(req, authService);
        if (!user)
            return reply.status(401).send({ error: 'Unauthorized' });
        const { name } = (req.body ?? {});
        if (!name || !name.trim()) {
            return reply.status(400).send({ error: 'name is required' });
        }
        try {
            const minted = authService.createApiToken(user.id, name);
            return reply.status(201).send(minted);
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // GET /api/auth/tokens — list the authenticated user's tokens (metadata only).
    fastify.get('/api/auth/tokens', async (req, reply) => {
        const user = requireUser(req, authService);
        if (!user)
            return reply.status(401).send({ error: 'Unauthorized' });
        return reply.send({ tokens: authService.listApiTokens(user.id) });
    });
    // DELETE /api/auth/tokens/:id — revoke one of the user's own tokens.
    fastify.delete('/api/auth/tokens/:id', async (req, reply) => {
        const user = requireUser(req, authService);
        if (!user)
            return reply.status(401).send({ error: 'Unauthorized' });
        const tokenId = parseInt(req.params.id, 10);
        if (isNaN(tokenId))
            return reply.status(400).send({ error: 'Invalid token id' });
        const ok = authService.revokeApiToken(tokenId, user.id);
        if (!ok)
            return reply.status(404).send({ error: 'Token not found' });
        return reply.send({ ok: true });
    });
    // GET /api/auth/seed — export auth seed data (admin only, for remote pull)
    // Phase 3: sanitized via the same registry-driven helper as
    // /api/config/bundle. jwtSecret + deprecated apiKey are dropped before
    // the response leaves the trust boundary.
    fastify.get('/api/auth/seed', async (req, reply) => {
        const admin = requireOwner(req, authService);
        if (!admin)
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const { sanitizeAuthSeed } = await import('../../core/secrets/bundle.js');
        return reply.send(sanitizeAuthSeed(authService.getSeedData()));
    });
    // POST /api/auth/transfer-ownership — transfer ownership to another user (owner only).
    // Accepts cookie OR Bearer sg_ token via requireUser (the CLI calls this
    // through `studiograph admin transfer-ownership`).
    fastify.post('/api/auth/transfer-ownership', async (req, reply) => {
        const user = requireUser(req, authService);
        if (!user)
            return reply.status(401).send({ error: 'Unauthorized' });
        if (user.role !== 'owner')
            return reply.status(403).send({ error: 'Only the workspace owner can transfer ownership' });
        const { targetUserId } = req.body;
        if (!targetUserId)
            return reply.status(400).send({ error: 'targetUserId is required' });
        try {
            authService.transferOwnership(user.id, targetUserId);
            return reply.send({ ok: true });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
    // POST /api/auth/login — authenticate and set session cookie
    fastify.post('/api/auth/login', async (req, reply) => {
        if (managedCloudAuthConfig().enabled) {
            return reply.status(403).send({ error: 'Use Studiograph Cloud to sign in to this managed workspace' });
        }
        const { email, password } = req.body;
        if (!email || !password) {
            return reply.status(400).send({ error: 'Email and password are required' });
        }
        // Rate limit check
        const retryAfter = checkRateLimit(email);
        if (retryAfter > 0) {
            reply.header('Retry-After', String(retryAfter));
            return reply.status(429).send({ error: 'Too many login attempts. Try again later.', retryAfter });
        }
        const result = authService.authenticate(email, password);
        if (!result) {
            recordFailedAttempt(email);
            return reply.status(401).send({ error: 'Invalid credentials' });
        }
        clearAttempts(email);
        const secure = req.headers['x-forwarded-proto'] === 'https';
        reply.setCookie('__sg_token', result.token, {
            path: '/',
            httpOnly: true,
            // Lax (not Strict): sent on top-level GET navigations initiated cross-site
            // — e.g. the OAuth /oauth/authorize redirect from Claude's connector — so an
            // already-logged-in user isn't bounced to a redundant login. Still withheld
            // on cross-site POSTs/subresources (the CSRF-dangerous cases).
            sameSite: 'lax',
            secure,
            maxAge: 7 * 24 * 60 * 60, // 7 days
        });
        // Include the token in the response body when requested via
        // X-Return-Token header for API clients that can't use cookies
        // across origins.
        const returnToken = req.headers['x-return-token'] === '1';
        return reply.send({
            user: result.user,
            ...(returnToken ? { token: result.token } : {}),
        });
    });
    // POST /api/auth/logout — clear session cookie
    fastify.post('/api/auth/logout', async (req, reply) => {
        const cloudAuth = managedCloudAuthConfig();
        reply.setCookie('__sg_token', '', {
            path: '/',
            httpOnly: true,
            sameSite: 'lax',
            maxAge: 0,
        });
        return reply.send({
            ok: true,
            logoutUrl: cloudAuth.enabled
                ? managedCloudLogoutUrl(cloudAuth, managedLogoutReturnTo(req))
                : undefined,
        });
    });
    // GET /api/auth/me — verify current session. Also resolves the user's
    // linked person entity (the auth↔person bridge) so the client gets both
    // identifiers in one round-trip. `personRef` is null when the user has
    // no linked entity (legacy, unprovisioned, or explicitly unlinked) OR
    // when the linked entity lives in a repo the user no longer has read
    // access to (would leak repo existence otherwise — see access-control).
    fastify.get('/api/auth/me', async (req, reply) => {
        // Resolve from the cookie JWT OR Authorization: Bearer (JWT / sg_ API
        // token) via the shared requireUser helper. The global auth hook skips
        // /api/auth/*, so without this the boot session check only honored the
        // web cookie and rejected API-token callers (CLI, automation, the
        // headless browser) even though they authenticate everywhere else.
        const user = requireUser(req, authService);
        if (!user) {
            return reply.status(401).send({ error: 'Not authenticated' });
        }
        const rawRef = findPersonRefForUser(workspaceManager, user.handle);
        const personRef = rawRef && access.hasRepoAccess(user, rawRef.repo, workspaceManager, authService)
            ? rawRef
            : null;
        return reply.send({ user, personRef });
    });
    // POST /api/auth/me/person-link — self-service link of the user↔person
    // bridge for the CURRENT user. Two modes:
    //   - body `{ entityId }` → link that SPECIFIC person the user picked
    //     (the home CTA / Profile "which record is you?" picker).
    //   - no body → auto: email-match an existing record, else create one
    //     (ensurePersonForUser).
    //
    // Self-scoped — the user can only stamp THEIR OWN handle onto an UNLINKED
    // person they can read — so it safely bypasses the owner-only gate that
    // guards generic user_handle writes (that gate exists to stop a member
    // claiming ANOTHER user's identity; linking yourself isn't that, and it
    // confers no privileges — auth role comes from the account, not the entity).
    fastify.post('/api/auth/me/person-link', async (req, reply) => {
        const user = requireUser(req, authService);
        if (!user)
            return reply.status(401).send({ error: 'Not authenticated' });
        const chosenId = typeof req.body?.entityId === 'string' ? req.body.entityId.trim() : '';
        // Idempotent / conflict: already linked?
        const existing = findPersonRefForUser(workspaceManager, user.handle);
        if (existing) {
            // Same record (or no specific request) → no-op success. A DIFFERENT
            // record → you're already linked; refuse rather than create a 2nd link.
            if (!chosenId || existing.entityId === chosenId) {
                return reply.send({ action: 'already-linked', personRef: existing });
            }
            return reply.status(409).send({
                error: `Your account is already linked to ${existing.repo}/${existing.entityId}. Unlink it first to link a different record.`,
                code: 'ALREADY_LINKED_ELSEWHERE',
            });
        }
        // Mode 1 — link a specific, user-picked person.
        if (chosenId) {
            const target = workspaceManager.findEntityByTypeAndId('person', chosenId);
            // Collapse "not found" and "can't read it" into one 404 so we don't leak
            // the existence of records in folders the caller can't access.
            if (!target || !access.hasRepoAccess(user, target.repoName, workspaceManager, authService)) {
                return reply.status(404).send({ error: `No person entry "${chosenId}" you can link.`, code: 'PERSON_NOT_FOUND' });
            }
            try {
                // Self-scoped link (own handle, unlinked target) — guards live in the
                // bridge helper; it throws BridgeError on conflict.
                await linkChosenPersonToUser(workspaceManager, user, { repo: target.repoName, entityId: chosenId }, authUserToGitUser(user));
                return reply.send({ action: 'linked', personRef: { repo: target.repoName, entityId: chosenId } });
            }
            catch (err) {
                if (err instanceof BridgeError)
                    return reply.status(409).send({ error: err.message, code: err.code, ...err.details });
                return reply.status(500).send({ error: err?.message ?? 'Failed to link person record' });
            }
        }
        // Mode 2 — auto (email-match an existing record, else create one).
        try {
            const r = await ensurePersonForUser(workspaceManager, authService, user, authUserToGitUser(user));
            if (r.action === 'skipped') {
                return reply.status(409).send({
                    error: `Could not create a person record (${r.reason}). Ask a workspace owner to designate a shared folder.`,
                    code: 'BRIDGE_SKIPPED',
                });
            }
            return reply.send({ action: r.action, personRef: r.ref });
        }
        catch (err) {
            return reply.status(500).send({ error: err?.message ?? 'Failed to link person record' });
        }
    });
    // PATCH /api/auth/me — update current user's profile
    fastify.patch('/api/auth/me', async (req, reply) => {
        const token = req.cookies?.['__sg_token'];
        if (!token)
            return reply.status(401).send({ error: 'Not authenticated' });
        const user = authService.verifyToken(token);
        if (!user)
            return reply.status(401).send({ error: 'Not authenticated' });
        const { displayName, email, newPassword } = req.body;
        if (newPassword) {
            authService.updatePassword(user.email, newPassword);
        }
        const updated = authService.updateProfile(user.id, {
            displayName: displayName !== undefined ? displayName : undefined,
            email: email !== undefined ? email : undefined,
        });
        return reply.send({ user: updated });
    });
    // GET /api/auth/colors — accent colors for all users (for avatar rendering)
    fastify.get('/api/auth/colors', async (_req, reply) => {
        return reply.send({ colors: authService.getAllAccentColors() });
    });
    // GET /api/auth/status — public endpoint for login page
    fastify.get('/api/auth/status', async (req, reply) => {
        const cloudAuth = managedCloudAuthConfig();
        return reply.send({
            authEnabled: authService.hasUsers(),
            userCount: authService.getUserCount(),
            workspaceName: workspaceName || undefined,
            managedAuth: cloudAuth.enabled,
            cloudUrl: cloudAuth.cloudUrl || undefined,
            managedAuthLoginUrl: managedCloudLoginUrl(cloudAuth) || undefined,
            managedAuthLogoutUrl: managedCloudLogoutUrl(cloudAuth, managedLogoutReturnTo(req)) || undefined,
        });
    });
    // POST /api/auth/setup — create the first admin account (only works when no users exist)
    fastify.post('/api/auth/setup', async (req, reply) => {
        if (managedCloudAuthConfig().enabled) {
            return reply.status(403).send({ error: 'Managed workspaces are set up from Studiograph Cloud' });
        }
        if (authService.hasUsers()) {
            return reply.status(403).send({ error: 'Setup already completed' });
        }
        const { email, password, displayName } = req.body;
        if (!email || !password) {
            return reply.status(400).send({ error: 'Email and password are required' });
        }
        try {
            const user = authService.createUser(email, password, displayName || email.split('@')[0], 'owner');
            const setupWorkspace = new Workspace(workspacePath);
            await ensurePersonalFolder(setupWorkspace, authService, user);
            // Provision the reserved workspace-wide "Assets" store once, at first-run
            // setup. Existing workspaces backfill lazily on first asset upload
            // (resolveWorkspaceAssetsRepo) — never via a boot sweep.
            await ensureWorkspaceAssetsFolder(setupWorkspace);
            workspaceManager.reloadConfig();
            // Grant the first owner folder-admin on every existing team folder.
            // Without this, folders scaffolded by `studiograph init` before any
            // user existed end up with zero folder_access rows, and
            // `getAccessibleRepos()` filters them out of the sidebar — the first
            // owner then sees an empty workspace despite the files being on disk.
            // Personal folders are handled by ensurePersonalFolder above; skipping
            // them here avoids granting the owner access to other users' personals.
            for (const repo of workspaceManager.getAllRepoConfigs()) {
                if (repo.personal)
                    continue;
                authService.grantFolderAccess(user.id, repo.name, 'admin');
            }
            // No first-owner person auto-provisioning: assignment + "My Tasks" key on
            // the account identity (`[[user:<id>]]`). The owner adds their profile to
            // the graph explicitly from Settings → Profile if they want one.
            const result = authService.authenticate(email, password);
            if (!result) {
                return reply.status(500).send({ error: 'Failed to authenticate after setup' });
            }
            const secure = req.headers['x-forwarded-proto'] === 'https';
            reply.setCookie('__sg_token', result.token, {
                path: '/',
                httpOnly: true,
                sameSite: 'lax',
                secure,
                maxAge: 7 * 24 * 60 * 60,
            });
            return reply.send({ user });
        }
        catch (err) {
            return reply.status(400).send({ error: err.message });
        }
    });
}
//# sourceMappingURL=auth-api.js.map