{
  "name": "Export Package Preflight v0.1.0",
  "nodes": [
    {
      "id": "wf014-manual",
      "name": "Manual test",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "parameters": {},
      "position": [
        0,
        0
      ]
    },
    {
      "id": "wf014-fixture",
      "name": "Fictional demonstration only",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "jsCode": "// Fictional metadata only; no real evidence, credentials, pinned output or embedded files.\nreturn [{json:{\n  \"profile\": \"article\",\n  \"slug\": \"demo-article\",\n  \"files\": [\n    {\n      \"path\": \"demo-article.md\",\n      \"bytes\": 2000\n    },\n    {\n      \"path\": \"demo-article.html\",\n      \"bytes\": 5000\n    },\n    {\n      \"path\": \"demo-article.pdf\",\n      \"bytes\": 9000\n    },\n    {\n      \"path\": \"assets/imagem-1.jpg\",\n      \"bytes\": 12000\n    },\n    {\n      \"path\": \"assets-manifest.json\",\n      \"bytes\": 1000\n    }\n  ],\n  \"expected_images\": [\n    \"assets/imagem-1.jpg\"\n  ]\n}}];"
      },
      "position": [
        280,
        0
      ]
    },
    {
      "id": "wf014-check",
      "name": "Offline advisory result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "jsCode": "const crypto = require('crypto');\nfunction validateExportPackage(input, crypto, ByteBuffer) {\n  const fail = message => { throw new Error(message); };\n  const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);\n  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);\n  const limits = { files: 32, file_bytes: 8 * 1024 * 1024, total_bytes: 16 * 1024 * 1024, text_bytes: 1024 * 1024, path_chars: 180, json_depth: 40, json_values: 20000 };\n  const hashValid = value => typeof value === 'string' && value.length === 64 && !/[^a-f0-9]/.test(value);\n  const pathValid = value => {\n    if (typeof value !== 'string' || !value.length || value.length > limits.path_chars || /[^A-Za-z0-9._/-]/.test(value) || !/^[A-Za-z0-9]/.test(value)) return false;\n    return value.split('/').every(part => part && part !== '.' && part !== '..' && !part.endsWith('.') && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\.|$)/i.test(part));\n  };\n  const dense = (value, min, max, label) => {\n    if (!Array.isArray(value) || value.length < min || value.length > max) fail('Invalid ' + label);\n    for (let i = 0; i < value.length; i++) if (!own(value, i)) fail('Sparse ' + label);\n    return value;\n  };\n  const checkedObject = (value, label, maximumKeys) => {\n    if (!object(value)) fail('Invalid ' + label);\n    const keys = Object.keys(value);\n    if (keys.length > maximumKeys) fail('Too many fields in ' + label);\n    for (const key of keys) {\n      const descriptor = Object.getOwnPropertyDescriptor(value, key);\n      if (!descriptor || !own(descriptor, 'value')) fail('Accessor fields are unsupported');\n    }\n    return keys;\n  };\n  const inputKeys = checkedObject(input, 'package', 5);\n  for (const key of inputKeys) if (!['profile', 'slug', 'files', 'expected_images'].includes(key)) fail('Unsupported package field');\n  if (!own(input, 'profile') || !['article', 'product'].includes(input.profile)) fail('Invalid profile');\n  const article = input.profile === 'article';\n  const slug = own(input, 'slug') ? input.slug : null;\n  if ((article || slug !== null) && (typeof slug !== 'string' || slug.length < 1 || slug.length > 120 || /[^a-z0-9-]/.test(slug) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))) fail('Invalid slug');\n  if (!own(input, 'files')) fail('Missing files');\n  dense(input.files, 1, limits.files, 'files');\n  if (article && !own(input, 'expected_images')) fail('Missing expected_images');\n  const imagePaths = article ? dense(input.expected_images, 1, 2, 'expected_images') : own(input, 'expected_images') ? dense(input.expected_images, 0, 0, 'expected_images for product') : [];\n  const imagesSeen = new Set();\n  for (const path of imagePaths) {\n    if (!pathValid(path) || !/^assets\\/[A-Za-z0-9._/-]+\\.(?:jpg|jpeg|png)$/.test(path)) fail('Invalid expected image path');\n    if (imagesSeen.has(path.toLowerCase())) fail('Duplicate expected image');\n    imagesSeen.add(path.toLowerCase());\n  }\n  const files = new Map();\n  const foldedPaths = new Set();\n  let declaredTotal = 0;\n  let encodedTotal = 0;\n  // Complete the size/schema pass for ALL files before decoding, hashing or scanning any.\n  for (const file of input.files) {\n    const keys = checkedObject(file, 'file', 4);\n    for (const key of keys) if (!['path', 'bytes', 'sha256', 'data'].includes(key)) fail('Unsupported file field');\n    if (!own(file, 'path') || !pathValid(file.path)) fail('Invalid file path');\n    if (foldedPaths.has(file.path.toLowerCase())) fail('Duplicate file path');\n    foldedPaths.add(file.path.toLowerCase());\n    if (!own(file, 'bytes') || !Number.isSafeInteger(file.bytes) || file.bytes < 1 || file.bytes > limits.file_bytes) fail('Invalid declared file size');\n    declaredTotal += file.bytes;\n    if (declaredTotal > limits.total_bytes) fail('Package exceeds total byte limit');\n    if (own(file, 'sha256') && !hashValid(file.sha256)) fail('Invalid SHA-256');\n    const textual = article\n      ? /\\.(?:md|html|json|txt)$/i.test(file.path) || /^(?:README|LICENSE|CHANGELOG)$/i.test(file.path)\n      : !/\\.(?:pdf|jpg|jpeg|png)$/i.test(file.path);\n    if (textual && file.bytes > limits.text_bytes) fail('Text file exceeds byte limit');\n    if (own(file, 'data')) {\n      if (typeof file.data !== 'string' || !file.data.length || file.data.length > 4 * Math.ceil(limits.file_bytes / 3)) fail('Invalid or oversized base64 data');\n      // The declared size predicts the exact encoded length; fail before allocation.\n      if (file.data.length !== 4 * Math.ceil(file.bytes / 3)) fail('Base64 length does not match declared size');\n      encodedTotal += file.data.length;\n      if (encodedTotal > 4 * Math.ceil(limits.total_bytes / 3) + limits.files * 4) fail('Package exceeds encoded byte limit');\n    }\n    files.set(file.path, { source: file, textual, bytes: null, text: null, json: null, hash: null, hashVerified: false, signature: false, content: false });\n  }\n  for (const path of foldedPaths) {\n    const parts = path.split('/');\n    for (let i = 1; i < parts.length; i++) if (foldedPaths.has(parts.slice(0, i).join('/'))) fail('File and directory path collision');\n  }\n  if (article) {\n    for (const path of [slug + '.md', slug + '.html', slug + '.pdf', 'assets-manifest.json', ...imagePaths]) if (!files.has(path)) fail('Missing required article file');\n  } else {\n    for (const choices of [['workflow.json'], ['README', 'README.md'], ['LICENSE', 'LICENSE.md', 'LICENSE.txt'], ['CHANGELOG', 'CHANGELOG.md']]) {\n      const found = choices.filter(path => files.has(path));\n      if (found.length !== 1) fail('Missing or ambiguous required product file');\n    }\n  }\n  const supplied = [...files.values()].filter(file => own(file.source, 'data'));\n  if (supplied.length && (!ByteBuffer || typeof ByteBuffer.from !== 'function')) fail('ByteBuffer adapter is required for supplied bytes');\n  if (supplied.some(file => own(file.source, 'sha256')) && (!crypto || typeof crypto.createHash !== 'function')) fail('SHA-256 adapter is required');\n  const digest = bytes => {\n    if (!crypto || typeof crypto.createHash !== 'function') fail('SHA-256 adapter is required');\n    return crypto.createHash('sha256').update(bytes).digest('hex');\n  };\n  const scanJson = (value, product) => {\n    const stack = [{ value, depth: 0 }];\n    let visited = 0;\n    while (stack.length) {\n      const item = stack.pop();\n      if (++visited > limits.json_values || item.depth > limits.json_depth) fail('JSON exceeds complexity limit');\n      const current = item.value;\n      if (typeof current === 'number' && !Number.isFinite(current)) fail('Non-finite JSON number');\n      if (typeof current === 'string') {\n        if (!product) continue;\n        if (/\\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+)\\b/i.test(current) || /\\bbearer\\s+\\S+/i.test(current) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(current) || /\\b(?:api[_ -]?key|access[_ -]?token|client[_ -]?secret|password)\\s*[:=]\\s*[\"']?\\S+/i.test(current)) fail('Possible secret in product JSON');\n        // Conservative heuristic: private/local hosts, tenant n8n, webhook/execution URLs,\n        // collaboration document links, embedded credentials and signed/tokenized URLs.\n        for (const match of current.matchAll(/\\b(?:https?|wss?):\\/\\/[^\\s<>\"'`\\\\]+/gi)) {\n          const url = match[0];\n          if (url.includes('%')) fail('Encoded URL in product content requires manual review');\n          const authority = url.slice(url.indexOf('://') + 3).split(/[/?#]/)[0];\n          const queryParts = url.split(/[?&#]/).slice(1);\n          for (const part of queryParts) {\n            const equals = part.indexOf('=');\n            if (equals < 0) continue;\n            const name = part.slice(0, equals).toLowerCase();\n            if (/(?:token|secret|password|signature|credential|api[_-]?key)/.test(name) || ['sig', 'key', 'auth'].includes(name) || name.startsWith('x-amz-') || name.startsWith('x-goog-')) fail('Possible private URL in product content');\n          }\n          const host = authority.toLowerCase().split(':')[0];\n          const privateIp = /^(?:0|10|127|169\\.254|192\\.168|172\\.(?:1[6-9]|2\\d|3[01]))(?:\\.|$)/.test(host);\n          if (authority.includes('@') || authority.includes('[') || !host.includes('.') || privateIp || /(?:^|\\.)(?:localhost|local|internal|lan|home|invalid)$/.test(host) || /(?:^|\\.)(?:n8n\\.cloud|slack\\.com|notion\\.so|docs\\.google\\.com|drive\\.google\\.com)$/.test(host) || /\\/(?:webhook(?:-test)?|workflow|workflows|execution|executions)(?:[/?#]|$)/i.test(url)) fail('Possible private URL in product JSON');\n        }\n        continue;\n      }\n      if (!current || typeof current !== 'object') continue;\n      const keys = Object.keys(current);\n      if (keys.length + visited + stack.length > limits.json_values) fail('JSON exceeds complexity limit');\n      for (const key of keys) {\n        const child = current[key];\n        const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');\n        if (product) {\n          if (/credential/.test(normalized)) fail('Credential reference in product JSON');\n          if (normalized === 'pindata' && (!object(child) || Object.keys(child).length)) fail('Real or malformed pinData in product JSON');\n          if (/(?:token|password|passwd|secret|apikey|authorization)/.test(normalized) && child !== null && child !== '' && child !== false) fail('Possible secret field in product JSON');\n          if (normalized === 'instanceid' && child) fail('Private instance identifier in product JSON');\n        }\n        stack.push({ value: child, depth: item.depth + 1 });\n      }\n    }\n  };\n  for (const file of supplied) {\n    const source = file.source;\n    if (source.data.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(source.data) || !/^[A-Za-z0-9+/]*={0,2}$/.test(source.data)) fail('Invalid canonical base64');\n    const bytes = ByteBuffer.from(source.data, 'base64');\n    if (bytes.length !== source.bytes || bytes.toString('base64') !== source.data) fail('Invalid canonical base64 or byte size mismatch');\n    file.bytes = bytes;\n    if (own(source, 'sha256')) {\n      file.hash = digest(bytes);\n      if (file.hash !== source.sha256) fail('File SHA-256 mismatch');\n      file.hashVerified = true;\n    }\n    if (/\\.pdf$/i.test(source.path)) {\n      if (bytes.length < 8 || !/^%PDF-[12]\\.[0-9]$/.test(bytes.subarray(0, 8).toString('latin1'))) fail('PDF signature mismatch');\n      file.signature = true;\n    } else if (/\\.(?:jpg|jpeg|png)$/i.test(source.path)) {\n      const png = bytes.length >= 8 && bytes.subarray(0, 8).toString('hex') === '89504e470d0a1a0a';\n      const jpg = bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;\n      if (!(/\\.png$/i.test(source.path) ? png : jpg)) fail('Image signature mismatch');\n      file.signature = true;\n    }\n    if (file.textual) {\n      const text = bytes.toString('utf8');\n      if (ByteBuffer.from(text, 'utf8').toString('base64') !== source.data || text.includes('\\u0000')) fail('Invalid UTF-8 text');\n      file.text = text;\n      if (!article && !/\\.json$/i.test(source.path)) scanJson(text, true);\n      if (/\\.json$/i.test(source.path)) {\n        try { file.json = JSON.parse(text); } catch { fail('Invalid JSON file'); }\n        if (!object(file.json)) fail('JSON file must contain an object');\n        scanJson(file.json, !article);\n        file.content = true;\n      }\n      if (/\\.html$/i.test(source.path)) {\n        const tags = [...text.matchAll(/<(\\/?)(html|head|body)(?:\\s[^<>]{0,2048})?>/gi)];\n        const order = tags.map(match => match[1] + match[2].toLowerCase()).join(',');\n        if (!/^\\s*<!doctype html\\s*>/i.test(text) || order !== 'html,head,/head,body,/body,/html' || text.slice(tags[5].index + tags[5][0].length).trim()) fail('Incomplete HTML structure');\n        file.content = true;\n      }\n    }\n  }\n  if (article) {\n    const manifest = files.get('assets-manifest.json').json;\n    if (manifest) {\n      dense(manifest.assets, 1, 2, 'manifest assets');\n      const manifestPaths = new Set();\n      for (const asset of manifest.assets) {\n        if (!object(asset) || !pathValid(asset.path) || !imagePaths.includes(asset.path) || manifestPaths.has(asset.path)) fail('Invalid or unexpected manifest asset');\n        manifestPaths.add(asset.path);\n        const target = files.get(asset.path);\n        if (!Number.isSafeInteger(asset.bytes) || asset.bytes !== target.source.bytes) fail('Manifest image size mismatch');\n        if (own(asset, 'sha256')) {\n          if (!hashValid(asset.sha256)) fail('Invalid manifest SHA-256');\n          if (own(target.source, 'sha256') && target.source.sha256 !== asset.sha256) fail('Conflicting image SHA-256');\n          if (target.bytes) {\n            const actual = target.hash || digest(target.bytes);\n            if (actual !== asset.sha256) fail('Manifest image SHA-256 mismatch');\n            target.hash = actual;\n            target.hashVerified = true;\n          }\n        }\n      }\n      if (manifestPaths.size !== imagePaths.length) fail('Missing manifest image reference');\n    }\n    const verifyReferences = (text, format) => {\n      if (text === null) return;\n      const refs = [];\n      if (format === 'md') {\n        for (const match of text.matchAll(/!\\[[^\\[\\]\\n]*\\]\\(([^()[\\]\\n]+)\\)/g)) refs.push(match[1]);\n        // Restrict image syntax rather than silently skipping reference-style/HTML images.\n        if ((text.match(/!\\[/g) || []).length !== refs.length || /<img\\b/i.test(text)) fail('Unsupported Markdown image syntax');\n      } else {\n        const tags = [...text.matchAll(/<img\\b([^<>]*)>/gi)];\n        if ((text.match(/<img\\b/gi) || []).length !== tags.length) fail('Malformed HTML image tag');\n        for (const match of tags) {\n          if (/(?:^|\\s)srcset\\s*=/i.test(match[1])) fail('Unsupported HTML image srcset');\n          const sources = [...match[1].matchAll(/(?:^|\\s)src\\s*=\\s*([\"'])(.*?)\\1/gi)];\n          if (sources.length !== 1) fail('Unsupported HTML image source');\n          refs.push(sources[0][2]);\n        }\n      }\n      if (!refs.length || refs.some(path => !pathValid(path) || !imagePaths.includes(path) || !files.has(path)) || imagePaths.some(path => !refs.includes(path))) fail('Missing or unexpected image reference');\n    };\n    verifyReferences(files.get(slug + '.md').text, 'md');\n    verifyReferences(files.get(slug + '.html').text, 'html');\n  } else {\n    const workflow = files.get('workflow.json').json;\n    if (workflow) {\n      dense(workflow.nodes, 1, 200, 'workflow nodes');\n      if (!object(workflow.connections) || (own(workflow, 'active') && workflow.active !== false)) fail('Workflow must be inactive with connections');\n      const names = new Set();\n      for (const node of workflow.nodes) {\n        if (!object(node) || typeof node.name !== 'string' || !node.name.trim() || node.name.length > 200 || names.has(node.name) || typeof node.type !== 'string' || !node.type.startsWith('n8n-nodes-base.')) fail('Invalid or unsupported workflow node');\n        names.add(node.name);\n      }\n    }\n  }\n  const summaries = [...files.values()].map(file => ({ path: file.source.path, bytes: file.source.bytes, bytes_supplied: file.bytes !== null, sha256_verified: file.hashVerified, signature_checked: file.signature, content_checked: file.content }));\n  const integrity = summaries.every(file => file.bytes_supplied && file.sha256_verified);\n  return {\n    profile: input.profile, slug, status: 'structure_valid', file_count: files.size, declared_total_bytes: declaredTotal,\n    expected_images: [...imagePaths], files: summaries,\n    supplied_bytes_file_count: supplied.length,\n    verified_paths: summaries.filter(file => file.sha256_verified).map(file => file.path),\n    byte_integrity_verified: integrity,\n    integrity_scope: 'supplied bytes versus declared SHA-256 only; no trusted provenance or archive verification',\n    secret_scan_scope: article ? 'not performed for article profile' : 'supplied product text and parsed JSON; heuristic checks require human review; PDF/image contents are not scanned',\n    visual_review_required: true, workflow_import_required: !article, rights_review_required: true,\n    human_security_review_required: !article, archive_extraction_required: true,\n    zip_integrity_verified: false, rendering_verified: false, release_approved: false,\n    limitations: [\n      'Metadata-only files receive structural checks only; missing bytes or hashes are not integrity-verified.',\n      'Signatures and HTML/JSON syntax do not prove correct rendering, decoding, safety, rights or importability.',\n      'Create and extract the ZIP separately, compare extracted hashes, and complete all applicable human gates before distribution.'\n    ]\n  };\n}\nreturn $input.all().map((item,index)=>({json:validateExportPackage(item.json,crypto,Buffer),pairedItem:{item:index}}));"
      },
      "position": [
        560,
        0
      ]
    }
  ],
  "connections": {
    "Manual test": {
      "main": [
        [
          {
            "node": "Fictional demonstration only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fictional demonstration only": {
      "main": [
        [
          {
            "node": "Offline advisory result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "pinData": {},
  "settings": {
    "executionOrder": "v1",
    "timezone": "Africa/Luanda",
    "saveDataSuccessExecution": "none",
    "saveDataErrorExecution": "none",
    "saveManualExecutions": false
  },
  "tags": []
}
