[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/bourgeoa/JavaScriptSolidServer/patchAppend/src/server.js [Back]  [Original]

import Fastify from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js';
import { handlePost, handleCreatePod, createPodStructure } from './handlers/container.js';
import * as storage from './storage/filesystem.js';
import { getCorsHeaders } from './ldp/headers.js';
import { authorize, handleUnauthorized } from './auth/middleware.js';
import { notificationsPlugin } from './notifications/index.js';
import { startFileWatcher } from './notifications/events.js';
import { idpPlugin } from './idp/index.js';
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
import { AccessMode } from './wac/parser.js';
import { registerNostrRelay } from './nostr/relay.js';
import { createPayHandler, isPayRequest } from './handlers/pay.js';
import { activityPubPlugin, getActorHandler } from './ap/index.js';
import { remoteStoragePlugin } from './remotestorage.js';
import { dbPlugin } from './db/index.js';
import { webrtcPlugin } from './webrtc/index.js';
import { tunnelPlugin } from './tunnel/index.js';
import { terminalPlugin } from './terminal/index.js';
import { registerErrorHandler } from './utils/error-handler.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
 * Create and configure Fastify server
 * @param {object} options - Server options
 * @param {boolean} options.logger - Enable logging (default true)
 * @param {boolean} options.conneg - Enable content negotiation for RDF (default false)
 * @param {boolean} options.notifications - Enable WebSocket notifications (default false)
 * @param {boolean} options.idp - Enable built-in Identity Provider (default false)
 * @param {string} options.idpIssuer - IdP issuer URL (default: server URL)
 * @param {object} options.ssl - SSL configuration { key, cert } (default null)
 * @param {string} options.root - Data directory path (default from env or ./data)
 * @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
 * @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
 * @param {boolean} options.git - Enable Git HTTP backend for clone/push (default false)
 * @param {boolean} options.nostr - Enable Nostr relay (default false)
 * @param {string} options.nostrPath - Nostr relay WebSocket path (default '/relay')
 * @param {number} options.nostrMaxEvents - Max events in relay memory (default 1000)
 * @param {boolean} options.activitypub - Enable ActivityPub federation (default false)
 * @param {string} options.apUsername - ActivityPub username (default 'me')
 * @param {string} options.apDisplayName - ActivityPub display name
 * @param {string} options.apSummary - ActivityPub bio/summary
 * @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
 * @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
 * @param {boolean} options.pay - Enable HTTP 402 paid /pay/* routes (default false)
 * @param {number} options.payCost - Cost per request in satoshis (default 1)
 * @param {string} options.payMempoolUrl - Mempool API base URL (default testnet4)
 * @param {string} options.payAddress - Pod's MRC20 address for receiving token transfers
 */
export function createServer(options = {}) {
  // Content negotiation is OFF by default - we're a JSON-LD native server
  const connegEnabled = options.conneg ?? false;
  // WebSocket notifications are OFF by default
  const notificationsEnabled = options.notifications ?? false;
  // Identity Provider is OFF by default
  const idpEnabled = options.idp ?? false;
  const idpIssuer = options.idpIssuer;
  // Subdomain mode is OFF by default - use path-based pods
  const subdomainsEnabled = options.subdomains ?? false;
  const baseDomain = options.baseDomain || null;
  // Mashlib data browser is OFF by default
  // mashlibCdn: load from CDN; mashlibModule: URL to ES module entry point
  const mashlibModule = options.mashlibModule ?? false;
  const mashlibCdn = options.mashlibCdn ?? false;
  const mashlibEnabled = mashlibCdn || !!mashlibModule;
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
  const gitEnabled = options.git ?? false;
  // Nostr relay is OFF by default
  const nostrEnabled = options.nostr ?? false;
  const nostrPath = options.nostrPath ?? '/relay';
  const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
  // WebRTC signaling is OFF by default
  const webrtcEnabled = options.webrtc ?? false;
  const webrtcPath = options.webrtcPath ?? '/.webrtc';
  // Terminal (WebSocket shell) is OFF by default
  const terminalEnabled = options.terminal ?? false;
  // Tunnel proxy is OFF by default
  const tunnelEnabled = options.tunnel ?? false;
  const tunnelPath = options.tunnelPath ?? '/.tunnel';
  // ActivityPub federation is OFF by default
  const activitypubEnabled = options.activitypub ?? false;
  const apUsername = options.apUsername ?? 'me';
  const apDisplayName = options.apDisplayName ?? options.apUsername ?? 'Anonymous';
  const apSummary = options.apSummary ?? '';
  const apNostrPubkey = options.apNostrPubkey ?? null;
  // Invite-only registration is OFF by default - open registration
  const inviteOnly = options.inviteOnly ?? false;
  // Single-user mode - creates pod on startup, disables registration
  const singleUser = options.singleUser ?? false;
  const singleUserName = options.singleUserName ?? 'me';
  const singleUserPassword = options.singleUserPassword ?? null;
  // Default storage quota per pod (50MB default, 0 = unlimited)
  const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
  // WebID-TLS client certificate authentication is OFF by default
  const webidTlsEnabled = options.webidTls ?? false;
  // Live reload - injects script to auto-refresh browser on file changes
  const liveReloadEnabled = options.liveReload ?? false;
  // MongoDB-backed /db/ route is OFF by default
  const mongoEnabled = options.mongo ?? false;
  const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
  const mongoDatabase = options.mongoDatabase ?? 'solid';
  // HTTP 402 paid /pay/ routes are OFF by default
  const payEnabled = options.pay ?? false;
  const payCost = options.payCost ?? 1;
  const payMempoolUrl = options.payMempoolUrl ?? 'https://mempool.space/testnet4';
  const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
  const payToken = options.payToken ?? null; // Token ticker for primary market
  const payRate = options.payRate ?? 1; // Sats per token
  const payChains = options.payChains ?? null; // Multi-chain IDs (e.g. "tbtc3,tbtc4")

  // Set data root via environment variable if provided
  if (options.root) {
    process.env.DATA_ROOT = options.root;
  }

  // Fastify options
  const loggerEnabled = options.logger ?? true;
  const fastifyOptions = {
    logger: loggerEnabled ? { level: options.logLevel || 'info' } : false,
    disableRequestLogging: true,
    trustProxy: true,
    // Force close connections on server.close() (useful for tests with WebSockets)
    forceCloseConnections: options.forceCloseConnections ?? false,
    // Handle raw body for non-JSON content
    bodyLimit: 10 * 1024 * 1024, // 10MB
    // Gracefully handle client TCP errors (ECONNRESET, EPIPE, etc.)
    clientErrorHandler: (err, socket) => {
      if (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ECONNABORTED') {
        socket.destroy();
        return;
      }
      // Default Fastify behavior for other client errors
      socket.destroy(err);
    }
  };

  // Add HTTPS support if SSL config provided
  if (options.ssl && options.ssl.key && options.ssl.cert) {
    fastifyOptions.https = {
      key: options.ssl.key,
      cert: options.ssl.cert,
    };

    // Enable client certificate request for WebID-TLS
    if (webidTlsEnabled) {
      fastifyOptions.https.requestCert = true;
      // Don't reject unauthorized - we verify via WebID profile, not CA chain
      fastifyOptions.https.rejectUnauthorized = false;
    }
  }

  const fastify = Fastify(fastifyOptions);
  registerErrorHandler(fastify);

  // Add raw body parser for all content types
  fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
    done(null, body);
  });

  // Git content types need explicit handling (binary data)
  fastify.addContentTypeParser('application/x-git-receive-pack-request', { parseAs: 'buffer' }, (req, body, done) => {
    done(null, body);
  });
  fastify.addContentTypeParser('application/x-git-upload-pack-request', { parseAs: 'buffer' }, (req, body, done) => {
    done(null, body);
  });

  // Attach server config to requests
  fastify.decorateRequest('connegEnabled', null);
  fastify.decorateRequest('notificationsEnabled', null);
  fastify.decorateRequest('idpEnabled', null);
  fastify.decorateRequest('subdomainsEnabled', null);
  fastify.decorateRequest('baseDomain', null);
  fastify.decorateRequest('podName', null);
  fastify.decorateRequest('mashlibEnabled', null);
  fastify.decorateRequest('mashlibCdn', null);
  fastify.decorateRequest('mashlibVersion', null);
  fastify.decorateRequest('mashlibModule', null);
  fastify.decorateRequest('defaultQuota', null);
  fastify.decorateRequest('config', null);
  fastify.decorateRequest('liveReloadEnabled', null);
  fastify.decorateRequest('singleUser', null);
  fastify.decorateRequest('singleUserName', null);
  fastify.addHook('onRequest', async (request) => {
    request.connegEnabled = connegEnabled;
    request.notificationsEnabled = notificationsEnabled || liveReloadEnabled;
    request.idpEnabled = idpEnabled;
    request.subdomainsEnabled = subdomainsEnabled;
    request.baseDomain = baseDomain;
    request.mashlibEnabled = mashlibEnabled;
    request.mashlibCdn = mashlibCdn;
    request.mashlibVersion = mashlibVersion;
    request.mashlibModule = mashlibModule;
    request.defaultQuota = defaultQuota;
    request.config = { public: options.public, readOnly: options.readOnly };
    request.liveReloadEnabled = liveReloadEnabled;
    request.singleUser = singleUser;
    request.singleUserName = singleUserName;

    // Extract pod name from subdomain if enabled
    if (subdomainsEnabled && baseDomain) {
      const host = request.hostname;
      // Check if host is a subdomain of baseDomain
      if (host !== baseDomain && host.endsWith('.' + baseDomain)) {
        // Extract subdomain (e.g., "alice.example.com" -> "alice")
        const subdomain = host.slice(0, -(baseDomain.length + 1));
        // Only single-level subdomains (no dots)
        if (!subdomain.includes('.')) {
          request.podName = subdomain;
        }
      }
    }
  });

  // Unified access log  one line per request
  fastify.addHook('onResponse', async (request, reply) => {
    if (!request.log.isLevelEnabled('info')) return;
    request.log.info({
      method: request.method,
      url: request.url,
      statusCode: reply.statusCode,
      remoteAddress: request.ip || request.headers['x-forwarded-for'] || request.socket?.remoteAddress,
      responseTime: Math.round(reply.elapsedTime * 100) / 100,
      userAgent: request.headers['user-agent'] || undefined,
      referrer: request.headers.referer || undefined,
      contentLength: reply.getHeader('content-length') || undefined,
    }, `${request.method} ${request.url} ${reply.statusCode} ${Math.round(reply.elapsedTime)}ms`);
  });

  // Register WebSocket notifications plugin if enabled (or live reload needs it)
  if (notificationsEnabled || liveReloadEnabled) {
    fastify.register(notificationsPlugin);
  }

  // Register Identity Provider plugin if enabled
  if (idpEnabled) {
    fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly, singleUser });
  }

  // Register Nostr relay if enabled
  if (nostrEnabled) {
    fastify.register(async (instance) => {
      await registerNostrRelay(instance, {
        path: nostrPath,
        maxEvents: nostrMaxEvents
      });
    });
  }

  // Register WebRTC signaling if enabled
  if (webrtcEnabled) {
    fastify.register(webrtcPlugin, { path: webrtcPath });
  }

  // Register terminal (WebSocket shell) if enabled
  if (terminalEnabled) {
    fastify.register(terminalPlugin, { path: '/.terminal', public: options.public || false });
  }

  // Register tunnel proxy if enabled
  if (tunnelEnabled) {
    fastify.register(tunnelPlugin, { path: tunnelPath });
  }

  // Register ActivityPub plugin if enabled
  if (activitypubEnabled) {
    fastify.register(activityPubPlugin, {
      username: apUsername,
      displayName: apDisplayName,
      summary: apSummary,
      nostrPubkey: apNostrPubkey
    });
  }

  // Register remoteStorage plugin (always on  no flag needed)
  fastify.register(remoteStoragePlugin, {
    username: singleUserName || 'me',
    ownerWebId: null  // single-user: any authenticated user can access
  });

  // Register MongoDB /db/ route if enabled
  if (mongoEnabled) {
    fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser });
  }

  // Register rate limiting plugin
  // Protects against brute force attacks and resource exhaustion
  fastify.register(rateLimit, {
    global: false, // Don't apply globally, only to specific routes
    max: 100, // Default max requests per window
    timeWindow: '1 minute',
    // Custom error response
    errorResponseBuilder: (request, context) => ({
      error: 'Too Many Requests',
      message: `Rate limit exceeded. Try again in ${Math.ceil(context.after / 1000)} seconds.`,
      retryAfter: Math.ceil(context.after / 1000)
    })
  });

  // Global CORS preflight
  fastify.addHook('onRequest', async (request, reply) => {
    // Add CORS headers to all responses
    const corsHeaders = getCorsHeaders(request.headers.origin);
    Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));

    // Add Updates-Via header for WebSocket notification discovery
    if (notificationsEnabled) {
      const wsProtocol = request.protocol === 'https' ? 'wss' : 'ws';
      reply.header('Updates-Via', `${wsProtocol}://${request.hostname}/.notifications`);
    }
    // Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
  });

  // ActivityPub actor endpoint - dedicated route for /profile/card.jsonld with AP Accept header
  // Registered before wildcard routes to take priority
  if (activitypubEnabled) {
    fastify.route({
      method: 'GET',
      url: '/profile/card.jsonld',
      handler: async (request, reply) => {
        const accept = request.headers.accept || '';
        const wantsAP = accept.includes('activity+json') ||
                        accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');

        const actorHandler = getActorHandler();
        if (wantsAP && actorHandler) {
          const actor = actorHandler(request);
          return reply
            .type('application/activity+json')
            .send(actor);
        }

        // Not AP request - serve the HTML profile from disk
        // This is handled by importing the resource handler
        const { handleGet } = await import('./handlers/resource.js');
        return handleGet(request, reply);
      }
    });
  }

  // Security: Block access to dotfiles except allowed Solid-specific ones
  // This prevents exposure of .git/, .env, .htpasswd, etc.
  // Git protocol requests bypass this check when git is enabled
  const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account'];
  fastify.addHook('onRequest', async (request, reply) => {
    // Allow git protocol requests through when git is enabled
    if (gitEnabled && isGitRequest(request.url)) {
      return;
    }

    // Allow pay routes through when pay is enabled (.balance, .deposit)
    if (payEnabled && isPayRequest(request.url)) {
      return;
    }

    // Allow WebRTC and tunnel endpoints through when enabled
    const urlNoQuery = request.url.split('?')[0];
    if (tunnelEnabled && (urlNoQuery === tunnelPath || urlNoQuery.startsWith('/tunnel/'))) {
      return;
    }
    if (webrtcEnabled && urlNoQuery === webrtcPath) {
      return;
    }
    if (terminalEnabled && urlNoQuery === '/.terminal') {
      return;
    }

    const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
    const hasForbiddenDotfile = segments.some(seg =>
      seg.startsWith('.') &&
      seg.length > 1 &&
      !ALLOWED_DOTFILES.includes(seg)
    );

    if (hasForbiddenDotfile) {
      return reply.code(403).send({ error: 'Forbidden', message: 'Dotfile access is not allowed' });
    }
  });

  // Git HTTP backend handler - uses git http-backend CGI
  // Authorization: Read for clone/fetch, Write for push
  if (gitEnabled) {
    fastify.addHook('preHandler', async (request, reply) => {
      if (!isGitRequest(request.url)) {
        return;
      }

      // Determine required mode: Write for push, Read for clone/fetch
      const needsWrite = isGitWriteOperation(request.url);
      const requiredMode = needsWrite ? AccessMode.WRITE : AccessMode.READ;

      // Run WAC authorization with the correct mode for git operations
      const { authorized, webId, wacAllow, authError, paymentRequired } = await authorize(request, reply, { requiredMode });
      request.webId = webId;
      request.wacAllow = wacAllow;

      if (paymentRequired) {
        return reply.code(402).send({ type: 'PaymentRequired', ...paymentRequired });
      }

      if (!authorized) {
        const message = needsWrite ? 'Write access required for push' : 'Read access required for clone';
        reply.header('WAC-Allow', wacAllow);
        if (!webId) {
          // No authentication - request Basic auth for git clients
          reply.header('WWW-Authenticate', 'Basic realm="Solid"');
        }
        return reply.code(webId ? 403 : 401).send({ error: message });
      }

      // Handle the git request directly
      return handleGit(request, reply);
    });
  }

  // HTTP 402 Payment Required handler for /pay/* routes
  if (payEnabled) {
    fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate, payChains }));
  }

  // Authorization hook - check WAC permissions
  // Skip for pod creation endpoint (needs special handling)
  fastify.addHook('preHandler', async (request, reply) => {
    // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
    const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
    const apPaths = ['/inbox', '/profile/card.jsonld/inbox', '/profile/card.jsonld/outbox', '/profile/card.jsonld/followers', '/profile/card.jsonld/following',
      '/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
      '/oauth/authorize', '/oauth/token'];
    // Check if request wants ActivityPub content for profile
    const accept = request.headers.accept || '';
    const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
    const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card.jsonld' || request.url.startsWith('/profile/card.jsonld?'));
    if (request.url === '/.pods' ||
        request.url === '/.notifications' ||
        request.method === 'OPTIONS' ||
        request.url === '/idp' ||
        request.url.startsWith('/idp/') ||
        request.url.startsWith('/idp?') ||
        request.url.startsWith('/.well-known/') ||
        (nostrEnabled && request.url.startsWith(nostrPath)) ||
        (gitEnabled && isGitRequest(request.url)) ||
        (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
        isProfileAP ||
        request.url.startsWith('/storage/') ||
        (payEnabled && isPayRequest(request.url)) ||
        (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
        (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
        (terminalEnabled && (request.url === '/.terminal' || request.url.startsWith('/.terminal?'))) ||
        (tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
        mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
      return;
    }

    const { authorized, webId, wacAllow, authError, paymentRequired, paid, balance, currency } = await authorize(request, reply);

    // Store webId and wacAllow on request for handlers to use
    request.webId = webId;
    request.wacAllow = wacAllow;

    // Set WAC-Allow header for all responses (handlers may override)
    reply.header('WAC-Allow', wacAllow);

    // Set payment headers for paid access
    if (paid !== undefined) {
      reply.header('X-Cost', String(paid));
      reply.header('X-Balance', String(balance));
      if (currency) reply.header('X-Pay-Currency', currency);
    }

    // Handle payment-gated resources
    if (paymentRequired) {
      return reply.code(402).send({
        type: 'PaymentRequired',
        ...paymentRequired
      });
    }

    if (!authorized) {
      return handleUnauthorized(request, reply, webId !== null, wacAllow, authError);
    }
  });

  // Pod creation endpoint with rate limiting
  // Limit: 1 pod per IP per day to prevent resource exhaustion and namespace squatting
  // Disabled in single-user mode
  if (singleUser) {
    fastify.post('/.pods', async (request, reply) => {
      return reply.code(403).send({ error: 'Forbidden', message: 'Pod creation disabled in single-user mode' });
    });
  } else {
    fastify.post('/.pods', {
      config: {
        rateLimit: {
          max: 1,
          timeWindow: '1 day',
          keyGenerator: (request) => request.ip
        }
      }
    }, handleCreatePod);
  }

  // Mashlib CDN mode: redirect chunk requests to CDN
  if (mashlibEnabled && mashlibCdn) {
    const cdnBase = `https://unpkg.com/mashlib@${mashlibVersion}/dist`;
    const chunkPattern = /^\/\d+\.mashlib\.min\.js(\.map)?$/;

    fastify.addHook('onRequest', async (request, reply) => {
      if (chunkPattern.test(request.url)) {
        const filename = request.url.split('/').pop();
        return reply.redirect(302, `${cdnBase}/${filename}`);
      }
    });
  }

  // Rate limit configuration for write operations
  // Protects against resource exhaustion and abuse
  const writeRateLimit = {
    config: {
      rateLimit: {
        max: 60,
        timeWindow: '1 minute',
        keyGenerator: (request) => request.webId || request.ip
      }
    }
  };

  // LDP routes - using wildcard routing
  // Read operations - no rate limit (handled by bodyLimit)
  fastify.get('/*', handleGet);
  fastify.head('/*', handleHead);
  fastify.options('/*', handleOptions);

  // Write operations - rate limited
  fastify.put('/*', writeRateLimit, handlePut);
  fastify.delete('/*', writeRateLimit, handleDelete);
  fastify.post('/*', writeRateLimit, handlePost);
  fastify.patch('/*', writeRateLimit, handlePatch);

  // Root route
  fastify.get('/', handleGet);
  fastify.head('/', handleHead);
  fastify.options('/', handleOptions);
  fastify.post('/', writeRateLimit, handlePost);

  // Single-user mode: create pod on startup if it doesn't exist
  if (singleUser) {
    fastify.addHook('onReady', async () => {
      // Determine base URL for pod URIs
      const protocol = options.ssl ? 'https' : 'http';
      const host = options.host === '0.0.0.0' ? 'localhost' : (options.host || 'localhost');
      const port = options.port || 3000;
      const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`;
      const issuer = idpIssuer || `${baseUrl}/`;

      // Root-level pod (empty or '/' name) vs named pod
      const isRootPod = !singleUserName || singleUserName === '/';
      const podPath = isRootPod ? '/' : `/${singleUserName}/`;
      const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
      const displayName = isRootPod ? 'me' : singleUserName;

      // Check if pod already exists. Accept either the new `card.jsonld`
      // or legacy extensionless `card` layout so we don't re-seed a pod
      // that was created by an older JSS version. Compute the effective
      // WebID against whichever profile file actually resolves  a
      // legacy pod must keep its `/profile/card#me` WebID, otherwise the
      // seeded IDP account would point at a non-existent document.
      const hasJsonLd = await storage.exists(`${podPath}profile/card.jsonld`);
      const hasLegacy = !hasJsonLd && await storage.exists(`${podPath}profile/card`);
      const profileFile = hasJsonLd ? 'profile/card.jsonld'
                          : hasLegacy ? 'profile/card'
                          : 'profile/card.jsonld'; // fresh pod default
      const webId = `${podUri}${profileFile}#me`;
      const profileExists = hasJsonLd || hasLegacy;

      if (!profileExists) {
        fastify.log.info(`Creating single-user pod at ${podUri}...`);

        if (isRootPod) {
          // Root-level pod - create structure directly at /
          await createRootPodStructure(webId, podUri, issuer, displayName);
        } else {
          // Named pod at /{name}/
          await createPodStructure(singleUserName, webId, podUri, issuer, defaultQuota);
        }
        fastify.log.info(`Single-user pod created at ${podUri}`);
      }

      // Seed an IDP account so the operator can actually log in. Without
      // this, single-user + --idp produces a pod but no credential, and
      // registration is intentionally disabled in single-user mode  so
      // the pod is unloggable until a password is set externally (#323).
      if (idpEnabled && !isRootPod) {
        await seedSingleUserIdpAccount({
          fastify,
          username: singleUserName,
          webId,
          podName: singleUserName,
          providedPassword: singleUserPassword
        });
      }
    });
  }

  /**
   * Seed an IDP account for the single-user pod owner if one doesn't
   * already exist. Password sources, in priority order:
   *   1. `--single-user-password` / `JSS_SINGLE_USER_PASSWORD`
   *   2. interactive prompt (TTY only)
   *   3. error  server stays up but logs that login won't work yet
   */
  async function seedSingleUserIdpAccount({ fastify, username, webId, podName, providedPassword }) {
    const { findByUsername, createAccount } = await import('./idp/accounts.js');
    const existing = await findByUsername(username);
    if (existing) return; // already seeded  idempotent

    // Treat anything that isn't a non-empty string as "not provided" so
    // a misconfigured env coercion or stray boolean can't reach bcrypt.
    let password = (typeof providedPassword === 'string' && providedPassword.length > 0)
      ? providedPassword
      : null;

    if (!password) {
      if (process.stdin.isTTY && process.stdout.isTTY) {
        try {
          password = await promptPasswordOnce(`[jss] Set initial IDP password for "${username}": `);
        } catch (err) {
          fastify.log.warn({ err }, `Password prompt failed for "${username}"`);
          return;
        }
      } else {
        fastify.log.warn(
          `--single-user --idp: no password provided. Set --single-user-password or ` +
          `JSS_SINGLE_USER_PASSWORD before starting (or run on a TTY to be prompted). ` +
          `Login is currently not possible for "${username}".`
        );
        return;
      }
    }

    if (typeof password !== 'string' || password.length === 0) {
      fastify.log.warn(`Empty password  skipping IDP account creation for "${username}".`);
      return;
    }

    try {
      await createAccount({ username, password, webId, podName });
      fastify.log.info(`IDP account seeded for single-user "${username}".`);
    } catch (err) {
      fastify.log.error({ err }, `Failed to seed IDP account for "${username}"`);
    }
  }

  /**
   * Read a password from stdin without echoing it. Uses the public
   * `emitKeypressEvents` + raw-mode keypress API rather than overriding
   * the underscored `_writeToOutput` on a `readline.Interface`, which is
   * a private/unstable hook.
   */
  async function promptPasswordOnce(prompt) {
    const { emitKeypressEvents } = await import('node:readline');
    const stdin = process.stdin;
    const stdout = process.stdout;
    if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
      throw new Error('Interactive password prompt requires a TTY');
    }
    return new Promise((resolve, reject) => {
      let password = '';
      const wasRaw = stdin.isRaw === true;
      const onKeypress = (str, key = {}) => {
        if (key.ctrl && key.name === 'c') {
          cleanup();
          reject(new Error('Password prompt cancelled'));
          return;
        }
        if (key.name === 'return' || key.name === 'enter') {
          cleanup();
          resolve(password);
          return;
        }
        if (key.name === 'backspace' || key.name === 'delete') {
          password = password.slice(0, -1);
          return;
        }
        // Only accept printable input  \P{C} excludes control codes,
        // so escape sequences from arrow keys, function keys, etc. don't
        // sneak invisible bytes into the password buffer.
        if (!key.ctrl && !key.meta &&
            typeof str === 'string' && str.length > 0 &&
            /^\P{C}+$/u.test(str)) {
          password += str;
        }
      };
      const cleanup = () => {
        stdin.removeListener('keypress', onKeypress);
        if (!wasRaw) stdin.setRawMode(false);
        stdout.write('\n');
        stdin.pause();
      };
      emitKeypressEvents(stdin);
      stdout.write(prompt);
      if (!wasRaw) stdin.setRawMode(true);
      stdin.resume();
      stdin.on('keypress', onKeypress);
    });
  }

  /**
   * Create root-level pod structure (for single-user mode with pod at /)
   */
  async function createRootPodStructure(webId, podUri, issuer, displayName) {
    const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js');
    const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } = await import('./wac/parser.js');

    // Create directories at root
    await storage.createContainer('/inbox/');
    await storage.createContainer('/public/');
    await storage.createContainer('/private/');
    await storage.createContainer('/settings/');
    await storage.createContainer('/profile/');

    // Generate profile
    const profile = generateProfile({ webId, name: displayName, podUri, issuer });
    await storage.write('/profile/card.jsonld', serialize(profile));

    // Preferences and type indexes
    const prefs = generatePreferences({ webId, podUri });
    await storage.write('/settings/prefs.jsonld', serialize(prefs));

    const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`, { listed: true });
    await storage.write('/settings/publicTypeIndex.jsonld', serialize(publicTypeIndex));

    const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`, { listed: false });
    await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex));

    // ACL files
    const rootAcl = generateOwnerAcl(podUri, webId, true);
    await storage.write('/.acl', serializeAcl(rootAcl));

    const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
    await storage.write('/private/.acl', serializeAcl(privateAcl));

    const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
    await storage.write('/settings/.acl', serializeAcl(settingsAcl));

    // publicTypeIndex: public read, overrides the private default inherited from /settings/
    const publicTypeIndexAcl = generateOwnerAcl(`${podUri}settings/publicTypeIndex.jsonld`, webId, false);
    await storage.write('/settings/publicTypeIndex.jsonld.acl', serializeAcl(publicTypeIndexAcl));

    const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
    await storage.write('/inbox/.acl', serializeAcl(inboxAcl));

    const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
    await storage.write('/public/.acl', serializeAcl(publicAcl));

    const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId);
    await storage.write('/profile/.acl', serializeAcl(profileAcl));

    // Note: Quota not initialized for root-level pods (no user directory)
  }

  // Start file watcher for live reload (watches filesystem for external changes)
  if (liveReloadEnabled) {
    const dataRoot = options.root || process.env.DATA_ROOT || './data';
    const protocol = options.ssl ? 'https' : 'http';
    // Use configured port, or default; actual URL will be localhost
    const port = options.port || 3000;
    const baseUrl = `${protocol}://localhost:${port}`;
    startFileWatcher(dataRoot, baseUrl);
  }

  return fastify;
}

/**
 * Start the server
 */
export async function startServer(port = 3000, host = '0.0.0.0') {
  const server = createServer();

  try {
    await server.listen({ port, host });
    return server;
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
}

Web Proxy Viewer  |  New URL  |  Original Page