← back to Rentv Adintel

lib/compliance/source-policy.js

198 lines

'use strict';
/**
 * Source-policy validator — the legal guardrail (spec §6, §9, §10).
 *
 * Enforces the "hard research, privacy, and source rules" in CODE, not docs:
 *   §6.1  no bypassing auth/paywalls/robots/rate-limits/anti-bot
 *   §6.2  no scraping proprietary DBs (CoStar, LoopNet, ZoomInfo, Apollo, MLS)
 *   §6.3  no crawling LinkedIn profile/company pages
 *   §6.4  block automated fetching from LinkedIn hosts in the generic fetcher
 *   §10   the SourcePolicy shape + access-method vocabulary
 *
 * A policy that names a prohibited proprietary DB or a LinkedIn host as its
 * base is ILLEGAL to enable for automation. validateSourcePolicy() flags it;
 * assertSourceEnabledLegal() throws so a pipeline can never run it.
 */

const T = require('../types');

/**
 * Prohibited proprietary databases (§6.2). Login-gated / proprietary — never
 * automate. LinkedIn hosts (§6.3/§6.4) are appended from the shared contract so
 * there is ONE list. MLS is matched by pattern below (any *.mls* / mls.* host).
 */
const PROHIBITED_PROPRIETARY_HOSTS = Object.freeze([
  'costar.com',
  'loopnet.com',
  'zoominfo.com',
  'apollo.io',
  'crexi.com',       // proprietary marketplace DB, login-gated bulk access
  'reonomy.com',     // proprietary property DB
]);

// Full automation blocklist = proprietary DBs + every LinkedIn host (§6.2-6.4).
const PROHIBITED_AUTOMATION_HOSTS = Object.freeze(
  PROHIBITED_PROPRIETARY_HOSTS.concat(T.LINKEDIN_BLOCKED_HOSTS)
);

/** Access methods that, by definition, perform NO automated fetching. */
const NON_AUTOMATED_METHODS = Object.freeze([
  'manual_upload',
  'manual_review_only',
  'authorized_mailbox', // mailbox is authorized-API, not web automation
]);

/** Lowercase, strip scheme/path/port → bare host. Accepts a bare host too. */
function hostOf(urlOrHost) {
  if (!urlOrHost) return '';
  const raw = String(urlOrHost).trim();
  try {
    const u = new URL(raw.includes('://') ? raw : `https://${raw}`);
    return u.hostname.toLowerCase();
  } catch (_e) {
    return raw.toLowerCase().replace(/^\/+/, '').split('/')[0].split(':')[0];
  }
}

/** True when host === blocked OR is a subdomain of blocked (a.b.costar.com). */
function hostMatches(host, blocked) {
  if (!host) return false;
  const h = host.toLowerCase();
  const b = blocked.toLowerCase();
  return h === b || h.endsWith(`.${b}`);
}

/** MLS systems are innumerable; match by pattern (§6.2 "any MLS"). */
function looksLikeMls(host) {
  if (!host) return false;
  const h = host.toLowerCase();
  // mls.<x>, <x>.mls.<y>, <x>mls.<y>, crmls/themls/etc.
  return (
    /(^|\.)mls\./.test(h) ||
    /(^|\.)[a-z]*mls\.[a-z]/.test(h) ||
    /\bmls\b/.test(h.replace(/[.-]/g, ' '))
  );
}

/** Is this host prohibited for AUTOMATED access? (proprietary DB, LinkedIn, MLS) */
function isProhibitedAutomationHost(host) {
  if (!host) return false;
  if (looksLikeMls(host)) return true;
  return PROHIBITED_AUTOMATION_HOSTS.some((b) => hostMatches(host, b));
}

/** §10 — the access method must be one of the seven contract values. */
function accessMethodAllowed(method) {
  return T.SOURCE_ACCESS_METHODS.includes(method);
}

/**
 * validateSourcePolicy(policy) → { valid, errors }
 * Never throws; collects every violation so the /sources UI can show them all.
 */
function validateSourcePolicy(policy) {
  const errors = [];

  if (!policy || typeof policy !== 'object') {
    return { valid: false, errors: ['policy must be an object'] };
  }

  // Required identity fields.
  if (!policy.sourceKey || typeof policy.sourceKey !== 'string') {
    errors.push('sourceKey is required');
  }
  if (!policy.displayName || typeof policy.displayName !== 'string') {
    errors.push('displayName is required');
  }

  // §10 access method vocabulary.
  if (!accessMethodAllowed(policy.accessMethod)) {
    errors.push(
      `accessMethod "${policy.accessMethod}" is not one of ${T.SOURCE_ACCESS_METHODS.join(', ')}`
    );
  }

  const base = hostOf(policy.baseUrl || policy.baseHost || '');
  const automated = policy.allowsAutomatedAccess === true;

  // Core rule (§6.2-6.4): a prohibited host may NOT allow automated access.
  if (automated && base && isProhibitedAutomationHost(base)) {
    errors.push(
      `base host "${base}" is a prohibited proprietary/LinkedIn/MLS source (§6.2-6.4); allowsAutomatedAccess must be false`
    );
  }

  // A prohibited host is only tolerable as manual_review_only / manual_upload.
  if (base && isProhibitedAutomationHost(base) && !NON_AUTOMATED_METHODS.includes(policy.accessMethod)) {
    errors.push(
      `base host "${base}" is prohibited for automation; accessMethod must be manual_review_only or manual_upload, got "${policy.accessMethod}"`
    );
  }

  // A web-automation access method combined with a prohibited base is illegal.
  const AUTOMATED_WEB_METHODS = ['first_party_public_web', 'rss_or_sitemap', 'official_bulk_download'];
  if (base && isProhibitedAutomationHost(base) && AUTOMATED_WEB_METHODS.includes(policy.accessMethod)) {
    errors.push(
      `access method "${policy.accessMethod}" performs web automation against prohibited host "${base}" (§6.1)`
    );
  }

  // Any prohibitedHosts entry that ALSO appears reachable is contradictory noise;
  // validate the declared prohibitedHosts are well-formed strings.
  if (policy.prohibitedHosts && !Array.isArray(policy.prohibitedHosts)) {
    errors.push('prohibitedHosts must be an array');
  }

  // If enabled, automated, and a web method, require conservative rate limits (§6.1, §23).
  if (policy.enabled && automated && AUTOMATED_WEB_METHODS.includes(policy.accessMethod)) {
    const rpm = policy.maxRequestsPerMinute;
    if (rpm != null && (typeof rpm !== 'number' || rpm <= 0 || rpm > 60)) {
      errors.push('maxRequestsPerMinute must be a sane 1-60 for automated web access (§23)');
    }
  }

  return { valid: errors.length === 0, errors };
}

/**
 * assertSourceEnabledLegal(policy) — throws if an ILLEGAL source is enabled for
 * automation. Called by the pipeline BEFORE any run (§10 "policy validation
 * before every run"). A disabled or non-automated policy is always allowed to
 * pass here (it does no fetching); only an ENABLED + AUTOMATED illegal source
 * is a hard stop.
 */
function assertSourceEnabledLegal(policy) {
  const { valid, errors } = validateSourcePolicy(policy);

  const base = hostOf((policy && (policy.baseUrl || policy.baseHost)) || '');
  const automated = policy && policy.allowsAutomatedAccess === true;
  const enabled = policy && policy.enabled === true;

  // The unforgivable combination: enabled + automated + prohibited host.
  if (enabled && automated && base && isProhibitedAutomationHost(base)) {
    throw new Error(
      `ILLEGAL SOURCE: "${(policy && policy.sourceKey) || base}" is enabled for automated access against prohibited host "${base}" (spec §6.2-6.4). Refusing to run.`
    );
  }

  // Any other structural invalidity on an enabled policy is also a stop.
  if (enabled && !valid) {
    throw new Error(
      `INVALID SOURCE POLICY "${(policy && policy.sourceKey) || '?'}": ${errors.join('; ')}`
    );
  }

  return true;
}

module.exports = {
  PROHIBITED_AUTOMATION_HOSTS,
  PROHIBITED_PROPRIETARY_HOSTS,
  validateSourcePolicy,
  assertSourceEnabledLegal,
  accessMethodAllowed,
  isProhibitedAutomationHost,
  looksLikeMls,
  hostOf,
};