/**
 * @file auth.ts
 * @description Authentication routes for challenge signing, JWT issuance,
 *   refresh, revocation, and session OTK flows.
 * @structure
 *   - authRouter() -- Express router for authentication endpoints
 *   - checkOtkSession() -- inactivity guard for session-bound OTKs
 *   - Challenge/session stores for interactive auth flows
 * @usage
 *   app.use(authRouter(config, storage));
 * @version-history
 *   v1.0.0 -- 2026-02-25 -- Initial authentication routes
 *   v1.1.0 -- 2026-05-28 -- Preserve agent identity and scopes during JWT refresh
 */
import { Router } from 'express';
import type { AimeatConfig } from '../config.js';
import type { Storage } from '../storage/interface.js';
import { verify } from '../auth/keypair.js';
import { issueJWT, revokeToken, generateSessionId } from '../auth/jwt.js';
import { requireAuth, requireRole, optionalAuth, isAnonymousMode, getAnonymousCredentials } from '../auth/middleware.js';
import { success, error } from '../middleware/envelope.js';
import { readRefreshCookie, refreshOwnerSession, hashToken, clearRefreshCookie } from '../services/owner-session.js';
import { resolvePat, PAT_PREFIX } from '../services/access-token.js';
import { parseGAII, validateAgentName, buildGAII } from '../utils/gaii.js';
import { randomBytes } from 'node:crypto';
import { generateOtk } from '../utils/otk.js';
import { AuthTokenRequestSchema, validateBody } from '../models/schemas.js';

// In-memory challenge store
const challenges = new Map<string, { challenge: string; expiresAt: number; owner: string }>();

// Session inactivity tracking: sessionId → lastActivity timestamp
const sessions = new Map<string, { ownerGaii: string; lastActivity: number }>();
const SESSION_INACTIVITY_MS = 5 * 60 * 1000; // 5 minutes

/**
 * Check if an OTK's session is still active (not timed out by inactivity).
 * Updates lastActivity on success. Returns false if session has expired.
 * Non-session OTKs always return true.
 */
export async function checkOtkSession(otk: { sessionId: string | null }, storage: Storage): Promise<boolean> {
  if (!otk.sessionId) return true;
  const session = sessions.get(otk.sessionId);
  if (!session) return true; // session not tracked (e.g. standalone OTK)
  if (Date.now() - session.lastActivity > SESSION_INACTIVITY_MS) {
    await storage.expireSessionOtks(otk.sessionId);
    sessions.delete(otk.sessionId);
    return false;
  }
  session.lastActivity = Date.now();
  return true;
}

export function authRouter(config: AimeatConfig, storage: Storage): Router {
  const router = Router();

  // POST /v1/auth/anonymous — get a JWT for anonymous access (when anonymous mode is enabled)
  router.post('/v1/auth/anonymous', async (req, res) => {
    if (!isAnonymousMode()) {
      res.status(403).json(error(config.nodeId, 'FORBIDDEN', 'Anonymous mode is not enabled on this node'));
      return;
    }

    const { gaii, owner } = getAnonymousCredentials();
    const token = await issueJWT({
      sub: gaii,
      owner,
      node: config.nodeId,
      roles: ['agent'],
      scopes: ['memory:read', 'memory:write', 'memory:delete', 'storage:read', 'storage:write', 'catalogue:read', 'social:read'],
    }, 86400); // 24 hours

    res.json(success(config.nodeId, {
      token,
      gaii,
      expires_in: 86400,
    }, [
      { description: 'Store data', method: 'POST', url: '/v1/memory' },
      { description: 'Create realtime room', method: 'POST', url: '/v1/realtime/rooms' },
    ]));
  });

  // GET /v1/auth/challenge — get a nonce to sign
  router.get('/v1/auth/challenge', (req, res) => {
    const owner = req.query.owner as string | undefined;
    if (!owner) {
      res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Query parameter "owner" is required'));
      return;
    }

    const challenge = `ch-${randomBytes(16).toString('hex')}`;
    const expiresAt = Date.now() + 60_000; // 60 seconds
    challenges.set(challenge, { challenge, expiresAt, owner });

    res.json(success(config.nodeId, {
      challenge,
      expires_at: new Date(expiresAt).toISOString(),
    }, [
      {
        description: 'Sign the challenge with your private key and submit to get a JWT',
        method: 'POST',
        url: '/v1/auth/token',
        example_body: {
          gaii: `your-agent#${owner}@${config.nodeId}`,
          timestamp: new Date().toISOString(),
          signature: 'base64(Ed25519_sign(private_key, gaii + timestamp))',
        },
      },
    ]));
  });

  // GET /v1/auth/session — Submit signed challenge, get OTK (Tier 0.5)
  router.get('/v1/auth/session', async (req, res) => {
    const owner = req.query.owner as string | undefined;
    const challengeStr = req.query.challenge as string | undefined;
    const sig = req.query.sig as string | undefined;

    if (!owner || !challengeStr || !sig) {
      res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Query parameters "owner", "challenge", and "sig" are required'));
      return;
    }

    // Look up challenge
    const stored = challenges.get(challengeStr);
    if (!stored) {
      res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Challenge not found or expired'));
      return;
    }
    if (Date.now() > stored.expiresAt) {
      challenges.delete(challengeStr);
      res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Challenge expired'));
      return;
    }
    if (stored.owner !== owner) {
      res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Challenge does not match owner'));
      return;
    }

    // Verify signature: owner signed the challenge string with their private key
    const ownerRecord = await storage.getOwner(owner);
    if (!ownerRecord) {
      res.status(404).json(error(config.nodeId, 'NOT_FOUND', `Owner not found: ${owner}`));
      return;
    }

    const valid = await verify(ownerRecord.publicKey, challengeStr, sig);
    if (!valid) {
      res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Invalid signature'));
      return;
    }

    // Consume challenge
    challenges.delete(challengeStr);

    // Find first agent for this owner (or create session OTK for owner)
    const agents = await storage.getAgentsByOwner(owner);
    const sessionGaii = agents.length > 0 ? agents[0].gaii : owner;

    // Create a session for inactivity tracking
    const sessionId = `sess-${randomBytes(8).toString('hex')}`;
    sessions.set(sessionId, { ownerGaii: sessionGaii, lastActivity: Date.now() });

    // Generate OTK for Tier 0.5 operations
    const otk = generateOtk();
    const expiresAt = new Date(Date.now() + config.otkTtlMs).toISOString();

    await storage.createOtk({
      key: otk,
      ownerGaii: sessionGaii,
      action: 'session',
      params: { owner, sessionType: 'tier_0_5', sessionId },
      expiresAt,
      initial: false,
      used: false,
      usedAt: null,
      sessionId,
      createdAt: new Date().toISOString(),
    });

    // Pre-rotate: generate next_otk so the AI always has a buffered key
    const nextOtk = generateOtk();
    const nextExpiresAt = new Date(Date.now() + config.otkTtlMs).toISOString();
    await storage.createOtk({
      key: nextOtk,
      ownerGaii: sessionGaii,
      action: 'session',
      params: { owner, sessionType: 'tier_0_5', sessionId },
      expiresAt: nextExpiresAt,
      initial: false,
      used: false,
      usedAt: null,
      sessionId,
      createdAt: new Date().toISOString(),
    });

    res.json(success(config.nodeId, {
      otk,
      otk_expires: expiresAt,
      next_otk: nextOtk,
      next_otk_expires: nextExpiresAt,
      session_id: sessionId,
      session_agent: sessionGaii,
      session_inactivity_timeout_seconds: SESSION_INACTIVITY_MS / 1000,
      otk_ttl_ms: config.otkTtlMs,
      otk_grace_ms: config.otkGraceMs,
      max_url_length: config.maxUrlLength,
      note: `OTKs remain valid for ${config.otkGraceMs / 1000} seconds after first use to handle retries. Session expires after 5 minutes of inactivity.`,
    }, [
      { description: 'Use OTK for micro-memory operations', method: 'GET', url: `/v1/mm?otk=${otk}&op=list` },
      { description: 'Accept work via GET', method: 'GET', url: `/v1/work/{tc}/accept?otk=${otk}` },
    ]));
  });

  // POST /v1/auth/token — exchange signature for JWT
  router.post('/v1/auth/token', validateBody(AuthTokenRequestSchema, config.nodeId), async (req, res) => {
    const { gaii, owner: ownerName, timestamp, signature } = req.body ?? {};

    // Agent auth (gaii provided)
    if (gaii) {
      const parsed = parseGAII(gaii);
      if (!parsed) {
        res.status(400).json(error(config.nodeId, 'INVALID_GAII', `Invalid GAII format: ${gaii}`));
        return;
      }

      const agent = await storage.getAgent(gaii);
      if (!agent) {
        res.status(404).json(error(config.nodeId, 'AGENT_NOT_FOUND', `Agent not found: ${gaii}`));
        return;
      }

      // Verify signature: sign(private_key, gaii + timestamp)
      const message = gaii + timestamp;
      const valid = await verify(agent.publicKey, message, signature);
      if (!valid) {
        res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Invalid signature'));
        return;
      }

      // Check timestamp freshness (within 5 minutes)
      const ts = new Date(timestamp).getTime();
      if (Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
        res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Timestamp too old or too far in the future'));
        return;
      }

      // Get owner to check roles
      const ownerRecord = await storage.getOwner(parsed.owner);
      const roles = ['agent'];
      if (ownerRecord?.roles.includes('owner')) roles.push('owner');
      if (ownerRecord?.roles.includes('operator')) roles.push('operator');

      // Self-heal: if no operator exists anywhere, promote this user
      if (ownerRecord && !roles.includes('operator')) {
        const allOwners = await storage.listOwners();
        const hasOperator = allOwners.some(o => o.roles.includes('operator'));
        if (!hasOperator) {
          roles.push('operator');
          await storage.updateOwner(parsed.owner, { roles: [...ownerRecord.roles, 'operator'] });
        }
      }

      // P3-7: Create session record for JWT tracking
      const sessionId = generateSessionId();
      const now = new Date();
      const expiresAt = new Date(now.getTime() + config.jwtTtlSeconds * 1000);

      const token = await issueJWT({
        sub: gaii,
        owner: parsed.owner,
        node: config.nodeId,
        roles,
        scopes: agent.defaultScopes,
      }, config.jwtTtlSeconds, sessionId);

      await storage.createSession({
        sessionId,
        gaii,
        owner: parsed.owner,
        issuedAt: now.toISOString(),
        expiresAt: expiresAt.toISOString(),
      });

      // Update last seen
      await storage.updateAgent(gaii, { lastSeen: new Date().toISOString() });

      res.json(success(config.nodeId, {
        token,
        expires_at: new Date(Date.now() + config.jwtTtlSeconds * 1000).toISOString(),
        ttl_seconds: config.jwtTtlSeconds,
        identity: {
          gaii,
          owner: parsed.owner,
          node: config.nodeId,
        },
        roles,
      }, [
        {
          description: 'Use this token in the Authorization header for all requests',
          note: `Authorization: Bearer ${token.slice(0, 20)}...`,
          method: 'GET',
          url: '/v1/memory',
        },
        { description: 'Refresh before expiry', method: 'POST', url: '/v1/auth/refresh' },
      ]));
      return;
    }

    // Owner auth (owner name provided instead of gaii)
    if (ownerName) {
      const ownerRecord = await storage.getOwner(ownerName);
      if (!ownerRecord) {
        res.status(404).json(error(config.nodeId, 'NOT_FOUND', `Owner not found: ${ownerName}`));
        return;
      }

      const message = ownerName + config.nodeId + timestamp;
      const valid = await verify(ownerRecord.publicKey, message, signature);
      if (!valid) {
        res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Invalid signature'));
        return;
      }

      const ts = new Date(timestamp).getTime();
      if (Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
        res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Timestamp too old or too far in the future'));
        return;
      }

      const roles = [...ownerRecord.roles];

      // P3-7: Create session record for JWT tracking
      const ownerSessionId = generateSessionId();
      const ownerNow = new Date();
      const ownerExpiresAt = new Date(ownerNow.getTime() + config.jwtTtlSeconds * 1000);

      const token = await issueJWT({
        sub: ownerName,
        owner: ownerName,
        node: config.nodeId,
        roles,
      }, config.jwtTtlSeconds, ownerSessionId);

      await storage.createSession({
        sessionId: ownerSessionId,
        gaii: ownerName,
        owner: ownerName,
        issuedAt: ownerNow.toISOString(),
        expiresAt: ownerExpiresAt.toISOString(),
      });

      res.json(success(config.nodeId, {
        token,
        expires_at: ownerExpiresAt.toISOString(),
        ttl_seconds: config.jwtTtlSeconds,
        identity: {
          owner: ownerName,
          node: config.nodeId,
        },
        roles,
      }, [
        { description: 'Register a new agent', method: 'POST', url: '/v1/agents' },
        { description: 'List your agents', method: 'GET', url: '/v1/agents' },
      ]));
      return;
    }

    res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Either "gaii" or "owner" is required'));
  });

  // POST /v1/auth/refresh
  // Two modes: (1) owner session refresh via the httpOnly aimeat_rt cookie with token
  // rotation + reuse detection; (2) legacy Bearer-based refresh for agents / pre-cookie
  // clients. optionalAuth() resolves a Bearer if present without rejecting cookie clients.
  router.post('/v1/auth/refresh', optionalAuth(), async (req, res) => {
    // (1) Cookie-based owner session refresh (rotation + reuse detection).
    if (readRefreshCookie(req)) {
      const cookieVal = readRefreshCookie(req)!;
      // PAT-backed browser session: the cookie value IS the access token (set by a browser
      // PAT request) — validate it per refresh so revocation takes effect immediately.
      if (cookieVal.startsWith(PAT_PREFIX)) {
        if (req.headers['x-aimeat-refresh'] !== '1') {
          res.status(400).json(error(config.nodeId, 'CSRF_REQUIRED', 'Missing X-AIMEAT-Refresh header'));
          return;
        }
        const r = await resolvePat(storage, cookieVal);
        if (!r) {
          clearRefreshCookie(req, res);
          res.status(401).json(error(config.nodeId, 'INVALID_TOKEN', 'Access token is invalid, revoked, or expired'));
          return;
        }
        await storage.touchPat(r.patId, new Date().toISOString());
        const includeScopes = r.roles.includes('agent');
        const patToken = await issueJWT({
          sub: r.sub, owner: r.owner, node: config.nodeId, roles: r.roles,
          ...(includeScopes ? { scopes: r.scopes } : {}),
        }, config.accessTtlSeconds);
        res.json(success(config.nodeId, {
          token: patToken,
          expires_in: config.accessTtlSeconds,
          expires_at: new Date(Date.now() + config.accessTtlSeconds * 1000).toISOString(),
          ttl_seconds: config.accessTtlSeconds,
        }));
        return;
      }
      const result = await refreshOwnerSession(storage, config, req, res);
      if (!result.ok) {
        res.status(result.status).json(error(config.nodeId, result.code, result.message));
        return;
      }
      res.json(success(config.nodeId, {
        token: result.token,
        expires_in: result.expiresIn,
        expires_at: new Date(Date.now() + result.expiresIn * 1000).toISOString(),
        ttl_seconds: result.expiresIn,
      }));
      return;
    }

    // (2) Legacy Bearer-based refresh (agents / pre-cookie clients).
    if (!req.auth || req.auth.anonymous) {
      res.status(401).json(error(config.nodeId, 'AUTH_REQUIRED', 'Authentication required'));
      return;
    }
    // Re-read roles from storage to prevent stale privilege persistence
    const ownerRecord = await storage.getOwner(req.auth!.owner);
    if (!ownerRecord) {
      res.status(401).json(error(config.nodeId, 'UNAUTHORIZED', 'Owner not found'));
      return;
    }

    let freshRoles: string[];
    let freshScopes: string[] | undefined;
    const isAgentSession = req.auth!.roles.includes('agent') && parseGAII(req.auth!.sub) !== null;

    if (isAgentSession) {
      const agent = await storage.getAgent(req.auth!.sub);
      if (!agent) {
        res.status(401).json(error(config.nodeId, 'UNAUTHORIZED', 'Agent no longer active'));
        return;
      }
      freshRoles = ['agent'];
      if (ownerRecord.roles.includes('owner')) freshRoles.push('owner');
      if (ownerRecord.roles.includes('operator')) freshRoles.push('operator');
      freshScopes = agent.defaultScopes;
    } else {
      freshRoles = ownerRecord.roles ?? ['owner'];
    }

    // P3-7: Create new session record for refreshed token
    const refreshSessionId = generateSessionId();
    const refreshNow = new Date();
    const refreshExpiresAt = new Date(refreshNow.getTime() + config.jwtTtlSeconds * 1000);

    const token = await issueJWT({
      sub: req.auth!.sub,
      owner: req.auth!.owner,
      node: config.nodeId,
      roles: freshRoles,
      ...(freshScopes !== undefined ? { scopes: freshScopes } : {}),
    }, config.jwtTtlSeconds, refreshSessionId);

    await storage.createSession({
      sessionId: refreshSessionId,
      gaii: req.auth!.sub,
      owner: req.auth!.owner,
      issuedAt: refreshNow.toISOString(),
      expiresAt: refreshExpiresAt.toISOString(),
    });

    res.json(success(config.nodeId, {
      token,
      expires_at: refreshExpiresAt.toISOString(),
      ttl_seconds: config.jwtTtlSeconds,
    }));
  });

  // POST /v1/auth/revoke
  // Logout: revoke the owner refresh session (identified by the cookie) and/or the
  // bearer token, then clear the refresh cookie. optionalAuth so it still works when
  // the short-lived access token has already expired.
  router.post('/v1/auth/revoke', optionalAuth(), async (req, res) => {
    const rt = readRefreshCookie(req);
    if (rt) {
      const session = await storage.getSessionByRefreshHash(hashToken(rt));
      if (session) await storage.revokeSession(session.sessionId);
      clearRefreshCookie(req, res);
    }

    const authHeader = req.headers.authorization;
    if (authHeader?.startsWith('Bearer ') && req.auth && !req.auth.anonymous) {
      const token = authHeader.slice(7);
      await revokeToken(token, req.auth.exp);
      if (req.auth.sessionId) await storage.revokeSession(req.auth.sessionId);
    }

    res.json(success(config.nodeId, {
      revoked: true,
    }, [
      { description: 'Get a new token', method: 'POST', url: '/v1/auth/token' },
    ]));
  });

  // GET /v1/auth/sessions — list active sessions for the authenticated owner
  router.get('/v1/auth/sessions', requireAuth(), async (req, res) => {
    const owner = req.auth!.owner;
    const sessions = await storage.listActiveSessions(owner);
    const currentSessionId = req.auth!.sessionId;

    res.json(success(config.nodeId, {
      sessions: sessions.map(s => ({
        session_id: s.sessionId,
        gaii: s.gaii,
        issued_at: s.issuedAt,
        expires_at: s.expiresAt,
        last_used_at: s.lastUsedAt ?? null,
        device_label: s.deviceLabel ?? null,
        current: s.sessionId === currentSessionId,
      })),
      total: sessions.length,
    }));
  });

  // DELETE /v1/auth/sessions/:id — revoke a specific session
  router.delete('/v1/auth/sessions/:id', requireAuth(), async (req, res) => {
    const sessionId = req.params.id as string;
    // Verify the session belongs to this owner by checking active sessions
    const sessions = await storage.listActiveSessions(req.auth!.owner);
    const target = sessions.find(s => s.sessionId === sessionId);
    if (!target) {
      res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Session not found or already revoked'));
      return;
    }
    await storage.revokeSession(sessionId);
    // If the caller revoked the device they're currently on, clear its cookie too.
    const rt = readRefreshCookie(req);
    if (rt && (hashToken(rt) === target.refreshTokenHash || hashToken(rt) === target.prevTokenHash)) {
      clearRefreshCookie(req, res);
    }
    res.json(success(config.nodeId, { revoked: true, session_id: sessionId }));
  });

  // DELETE /v1/auth/sessions — revoke all sessions for the authenticated owner (P3-7)
  router.delete('/v1/auth/sessions', requireAuth(), async (req, res) => {
    const owner = req.auth!.owner;
    const count = await storage.revokeAllSessions(owner);
    clearRefreshCookie(req, res); // the caller's own device is included in "all"

    res.json(success(config.nodeId, {
      revoked_sessions: count,
      owner,
    }, [
      { description: 'Get a new token', method: 'POST', url: '/v1/auth/token' },
    ]));
  });

  // POST /v1/auth/otk — generate a one-time key for Tier 0.5 actions (agent auth)
  router.post('/v1/auth/otk', requireAuth(), async (req, res) => {
    const { action, params } = req.body ?? {};
    if (!action) {
      res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'action is required (e.g. write_memory, post_board)'));
      return;
    }

    const allowedActions = ['write_memory', 'post_board'];
    if (!allowedActions.includes(action)) {
      res.status(400).json(error(config.nodeId, 'INVALID_INPUT', `action must be one of: ${allowedActions.join(', ')}`));
      return;
    }

    const key = generateOtk();
    const expiresAt = new Date(Date.now() + 600_000).toISOString(); // 10 minutes

    await storage.createOtk({
      key,
      ownerGaii: req.auth!.sub,
      action,
      params: params ?? {},
      expiresAt,
      initial: false,
      used: false,
      usedAt: null,
      sessionId: null,
      createdAt: new Date().toISOString(),
    });

    res.status(201).json(success(config.nodeId, {
      otk: key,
      action,
      expires_at: expiresAt,
      usage_url: `/v1/otk/${key}`,
      note: 'This key can be used once via GET request. Share with a Tier 0 agent to allow a single write operation.',
    }, [
      { description: 'Use this one-time key', method: 'GET', url: `/v1/otk/${key}` },
    ]));
  });

  // POST /v1/auth/initial-otk — generate an Initial OTK (timer starts on first use)
  // The OTK remains dormant until the AI first uses it. Once used, the grace period starts.
  // Ideal for embedding in prompts — the consumer can use it hours/days later.
  router.post('/v1/auth/initial-otk', requireAuth(), async (req, res) => {
    const key = generateOtk();
    // Far-future expiry — effectively no expiry until first use activates the timer
    const farFuture = new Date(Date.now() + 365 * 24 * 60 * 60_000).toISOString();

    await storage.createOtk({
      key,
      ownerGaii: req.auth!.sub,
      action: 'initial',
      params: {},
      expiresAt: farFuture,
      initial: true,
      used: false,
      usedAt: null,
      sessionId: null,
      createdAt: new Date().toISOString(),
    });

    res.status(201).json(success(config.nodeId, {
      otk: key,
      initial: true,
      grace_ms: config.otkGraceMs,
      note: `This is an Initial OTK. It has no expiry until first use. Once used, it remains valid for ${config.otkGraceMs / 1000} seconds. Embed it in prompts for AI agents.`,
      owner: req.auth!.sub,
    }, [
      { description: 'Use for micro-memory operations', method: 'GET', url: `/v1/mm?otk=${key}&op=list` },
      { description: 'Generate AI prompt with this OTK', method: 'GET', url: `/v1/prompts/tier0?otk=${key}` },
    ]));
  });

  // POST /v1/auth/connectivity-key — generate a connectivity key for AI agent registration
  router.post('/v1/auth/connectivity-key', requireAuth(), requireRole('owner'), async (req, res) => {
    const { agent_name, description } = req.body ?? {};
    const owner = req.auth!.owner;

    if (agent_name) {
      const nameError = validateAgentName(agent_name);
      if (nameError) {
        res.status(400).json(error(config.nodeId, 'INVALID_INPUT', nameError));
        return;
      }

      const gaii = buildGAII(agent_name, owner, config.nodeId);
      const existing = await storage.getAgent(gaii);
      if (existing) {
        res.status(409).json(error(config.nodeId, 'NAME_TAKEN', `Agent "${agent_name}" already exists under your identity`));
        return;
      }
    }

    const key = generateOtk();
    const farFuture = new Date(Date.now() + 365 * 24 * 60 * 60_000).toISOString();

    await storage.createOtk({
      key,
      ownerGaii: req.auth!.sub,
      action: 'register_agent',
      params: {
        owner,
        agent_name: agent_name ?? null,
        description: description ?? null,
      },
      expiresAt: farFuture,
      initial: true,
      used: false,
      usedAt: null,
      sessionId: null,
      createdAt: new Date().toISOString(),
    });

    res.set('Cache-Control', 'no-store');
    res.set('Pragma', 'no-cache');
    res.status(201).json(success(config.nodeId, {
      connectivity_key: key,
      owner,
      agent_name: agent_name ?? null,
    }));
  });

  // GET /v1/otk/:key — execute a one-time key action (no auth required — Tier 0.5)
  router.get('/v1/otk/:key', async (req, res) => {
    const key = req.params.key as string;
    const otk = await storage.consumeOtk(key, config.otkGraceMs);
    if (!otk) {
      res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'One-time key not found, expired, or already used'));
      return;
    }

    // Check session inactivity timeout
    if (otk.sessionId) {
      const session = sessions.get(otk.sessionId);
      if (session && Date.now() - session.lastActivity > SESSION_INACTIVITY_MS) {
        await storage.expireSessionOtks(otk.sessionId);
        sessions.delete(otk.sessionId);
        res.status(401).json(error(config.nodeId, 'SESSION_EXPIRED', 'Session expired due to inactivity'));
        return;
      }
      if (session) session.lastActivity = Date.now();
    }

    if (otk.action === 'write_memory') {
      const { key: memKey, value, visibility } = otk.params as any;
      if (!memKey || value === undefined) {
        res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'OTK params must include key and value'));
        return;
      }
      const existing = await storage.getMemory(otk.ownerGaii, memKey);
      await storage.setMemory({
        key: memKey,
        ownerGaii: otk.ownerGaii,
        value,
        visibility: visibility ?? 'private',
        tags: [],
        ttlHours: null,
        version: existing ? existing.version + 1 : 1,
        createdAt: existing?.createdAt ?? new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      });
      res.json(success(config.nodeId, { action: 'write_memory', key: memKey, written: true }));
      return;
    }

    res.status(400).json(error(config.nodeId, 'INVALID_INPUT', `Unsupported OTK action: ${otk.action}`));
  });

  // Cleanup expired challenges and inactive sessions periodically
  setInterval(() => {
    const now = Date.now();
    for (const [key, val] of challenges) {
      if (now > val.expiresAt) challenges.delete(key);
    }
    // Expire inactive sessions (5 min inactivity)
    for (const [sessionId, session] of sessions) {
      if (now - session.lastActivity > SESSION_INACTIVITY_MS) {
        storage.expireSessionOtks(sessionId).catch(() => { });
        sessions.delete(sessionId);
      }
    }
  }, 30_000);

  return router;
}
