Ctrl K
ComunidadGuías…

listing-fingerprint.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * Versioned local listing identity (#1030). Only a resolved ATS-native triple
 * authorizes a match. URL context never does; JD similarity remains owned by
 * fingerprint-core.mjs. See docs/listing-fingerprint.md for the v1 contract.
 */
import { createHash } from 'node:crypto';
import { isMainModule } from './lib/is-main-module.mjs';

export const LISTING_FINGERPRINT_VERSION = 1;
const TRIPLE_FIELDS = ['ats_provider', 'board_slug', 'posting_id'];
const RECORD_FIELDS = ['schema_version', 'identity', 'strong', 'canonical_host', 'canonical_path', 'listing_key'];

function object(value, name, fields) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)
      || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
    throw new TypeError(`${name} must be an object`);
  }
  if (Reflect.ownKeys(value).some(key => !fields.includes(key))) {
    throw new TypeError(`${name} contains unsupported fields`);
  }
  if (Object.values(Object.getOwnPropertyDescriptors(value)).some(descriptor => !Object.hasOwn(descriptor, 'value'))) {
    throw new TypeError(`${name} must contain data properties, not accessors`);
  }
  if (fields.some(field => field in value && !Object.hasOwn(value, field))) {
    throw new TypeError(`${name} must use own properties`);
  }
}

// Keep board/posting identifiers opaque: no casing, Unicode normalization,
// punctuation removal, URL decoding or numeric coercion can merge two IDs.
function triple(value, allowIncomplete = false) {
  object(value, 'strong', TRIPLE_FIELDS);
  let missing = false;
  for (const field of TRIPLE_FIELDS) {
    const part = value[field];
    if (part === undefined) {
      missing = true;
      continue;
    }
    if (typeof part !== 'string') throw new TypeError(`strong.${field} must be a string`);
    if (!part.trim()) {
      missing = true;
      continue;
    }
    if (part !== part.trim() || /[\u0000-\u001f\u007f]/u.test(part)) {
      throw new TypeError(`strong.${field} must not contain controls or surrounding whitespace`);
    }
    if (field === 'ats_provider' && !/^[a-z][a-z0-9_-]*$/.test(part)) {
      throw new TypeError('strong.ats_provider must be a lowercase provider slug');
    }
  }
  if (missing) {
    if (allowIncomplete) return null;
    throw new TypeError('strong requires ats_provider, board_slug and posting_id');
  }
  return Object.fromEntries(TRIPLE_FIELDS.map(field => [field, value[field]]));
}

function keyFor(strong) {
  const bytes = JSON.stringify(['listing-fingerprint', LISTING_FINGERPRINT_VERSION,
    ...TRIPLE_FIELDS.map(field => strong[field])]);
  return `listing_v1_${createHash('sha256').update(bytes, 'utf8').digest('hex')}`;
}

function urlContext(value) {
  if (value === undefined || value === null) return { canonical_host: null, canonical_path: null };
  if (typeof value !== 'string' || !/^https?:\/\//i.test(value)
      || value !== value.trim() || /[\u0000-\u001f\u007f]/u.test(value)) {
    throw new TypeError('url must be an absolute HTTP(S) URL without credentials');
  }
  let parsed;
  try { parsed = new URL(value); } catch {
    throw new TypeError('url must be an absolute HTTP(S) URL without credentials');
  }
  if (!/^https?:$/.test(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password) {
    throw new TypeError('url must be an absolute HTTP(S) URL without credentials');
  }
  return {
    canonical_host: parsed.host,
    canonical_path: parsed.pathname.replace(/\/+$/, '') || '/',
  };
}

/**
 * Create a local v1 record from public URL context and an explicitly resolved
 * ATS triple. An absent/incomplete triple produces partial context and no key.
 * Unknown fields and malformed values throw; the input is never modified.
 * @param {{url?: string, strong?: {ats_provider?: string, board_slug?: string, posting_id?: string}}} input
 * @returns {object} A validated-by-construction listing fingerprint record.
 */
export function computeListingFingerprint(input = {}) {
  object(input, 'input', ['url', 'strong']);
  const strong = input.strong === undefined ? null : triple(input.strong, true);
  return {
    schema_version: LISTING_FINGERPRINT_VERSION,
    identity: strong ? 'strong' : 'partial',
    ...(strong ? { strong } : {}),
    ...urlContext(input.url),
    listing_key: strong ? keyFor(strong) : null,
  };
}

/**
 * Validate a persisted local record, including its version and derived key.
 * Validation checks shape/consistency, not whether an ATS actually issued an ID.
 * @param {unknown} record - Parsed JSON to validate.
 * @returns {{valid: boolean, errors: string[]}} Does not coerce or repair input.
 */
export function validateListingFingerprint(record) {
  try {
    object(record, 'record', RECORD_FIELDS);
    if (record.schema_version !== LISTING_FINGERPRINT_VERSION) {
      throw new TypeError('unsupported schema_version; expected 1');
    }
    if (!['strong', 'partial'].includes(record.identity)) {
      throw new TypeError('identity must be strong or partial');
    }
    const { canonical_host: host, canonical_path: path } = record;
    if (host !== null || path !== null) {
      if (typeof host !== 'string' || typeof path !== 'string' || !host || !path.startsWith('/')) {
        throw new TypeError('canonical_host and canonical_path must be normalized strings or both null');
      }
      // Scheme is deliberately absent from context. Either scheme may have
      // produced a non-default port (e.g. http://example.test:443/jobs).
      const normalized = ['http:', 'https:'].some(scheme => {
        try {
          const context = urlContext(`${scheme}//${host}${path}`);
          return context.canonical_host === host && context.canonical_path === path;
        } catch { return false; }
      });
      if (!normalized) throw new TypeError('canonical_host and canonical_path must be normalized');
    }
    if (record.identity === 'strong') {
      const strong = triple(record.strong);
      if (record.listing_key !== keyFor(strong)) throw new TypeError('listing_key does not match the strong triple');
    } else if (Object.hasOwn(record, 'strong') || record.listing_key !== null) {
      throw new TypeError('partial identity must omit strong and have a null listing_key');
    }
    return { valid: true, errors: [] };
  } catch (error) {
    return { valid: false, errors: [error.message] };
  }
}

/**
 * Return a validated strong key, or null for partial context. Callers must
 * refuse a cache hit/match for null; two partial records are not an identity.
 * @param {unknown} record - Local record (not a raw provider response).
 * @returns {string|null} Throws TypeError when the record is invalid.
 */
export function listingKey(record) {
  const result = validateListingFingerprint(record);
  if (!result.valid) throw new TypeError(result.errors.join('; '));
  return record.listing_key;
}

if (isMainModule(import.meta.url)) {
  const args = process.argv.slice(2);
  if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
    console.log('Usage: node listing-fingerprint.mjs [--validate] \'<JSON>\'\n'
      + 'Compute a local record from {url?, strong?}, or validate a stored record. No files are written.');
  } else {
    const validate = args[0] === '--validate';
    const values = validate ? args.slice(1) : args;
    try {
      if (values.length !== 1) throw new TypeError('expected one JSON argument; use --help');
      let input;
      try { input = JSON.parse(values[0]); } catch { throw new TypeError('invalid JSON'); }
      const result = validate ? validateListingFingerprint(input) : computeListingFingerprint(input);
      console.log(JSON.stringify(result, null, 2));
      if (validate && !result.valid) process.exitCode = 1;
    } catch (error) {
      console.error(`listing-fingerprint: ${error.message}`);
      process.exitCode = 1;
    }
  }
}