{
  "name": "W3 - IN Messenger Adapter (Secure)",
  "active": true,
  "settings": {
    "executionTimeout": 300,
    "saveExecutionProgress": true,
    "saveManualExecutions": true
  },
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "v1/inbound/messenger",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "c5a28f71-7aca-4c6e-940b-6f6f69730d47",
      "name": "IN - Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        -2400,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "\nfunction extractAttachments(msg) {\n  const attachments = [];\n  if (msg?.attachments && Array.isArray(msg.attachments)) {\n    for (const a of msg.attachments) {\n      if (a.type === 'audio' && a.payload?.url) {\n        attachments.push({ type: 'audio', url: a.payload.url, mime: 'audio/mp4' });\n      } else if (a.type === 'image' && a.payload?.url) {\n        attachments.push({ type: 'image', url: a.payload.url, mime: 'image/jpeg' });\n      } else if (a.type === 'video' && a.payload?.url) {\n        attachments.push({ type: 'video', url: a.payload.url, mime: 'video/mp4' });\n      } else if (a.type === 'file' && a.payload?.url) {\n        attachments.push({ type: 'document', url: a.payload.url, mime: 'application/octet-stream' });\n      } else if (a.type === 'ig_voice' && a.payload?.url) {\n        attachments.push({ type: 'audio', url: a.payload.url, mime: 'audio/mp4' });\n      }\n    }\n  }\n  return attachments;\n}\n\nfunction parseMetaNative(rawBody) {\n  if (!rawBody || typeof rawBody !== 'object') return null;\n  if (rawBody.object !== 'page' && rawBody.object !== 'instagram') return null;\n  \n  const entry = rawBody.entry?.[0];\n  if (!entry) return null;\n  \n  const messaging = entry.messaging?.[0];\n  if (!messaging) return null;\n  \n  if (messaging.delivery || messaging.read || messaging.optin) {\n    return { _isStatusUpdate: true, _ignore: true };\n  }\n  \n  const msg = messaging.message;\n  if (!msg && !messaging.postback) return null;\n  \n  let text = msg?.text || messaging.postback?.payload || messaging.postback?.title || '';\n  \n  // Convert epoch timestamp to ISO 8601\n  const epochTs = messaging.timestamp;\n  let isoTimestamp;\n  if (epochTs) {\n    const epochNum = Number(epochTs);\n    const msTs = epochNum > 9999999999 ? epochNum : epochNum * 1000;\n    isoTimestamp = new Date(msTs).toISOString();\n  } else {\n    isoTimestamp = new Date().toISOString();\n  }\n  \n  const provider = rawBody.object === 'instagram' ? 'ig' : 'msg';\n  \n  return {\n    _isMetaNative: true,\n    provider,\n    msg_id: msg?.mid || messaging.postback?.mid || '',\n    from: messaging.sender?.id || '',\n    text: text,\n    timestamp: isoTimestamp,\n    attachments: extractAttachments(msg),\n    meta: {\n      recipient_id: messaging.recipient?.id || '',\n    },\n    raw_meta_message: msg || messaging.postback\n  };\n}\n\nconst rawBodyInput = $json.body ?? $json;\nconst metaNativeParsed = parseMetaNative(rawBodyInput);\n\nlet body;\nlet isMetaNative = false;\nlet isStatusUpdate = false;\n\nif (metaNativeParsed && metaNativeParsed._isStatusUpdate) {\n  isStatusUpdate = true;\n  body = rawBodyInput;\n} else if (metaNativeParsed && metaNativeParsed._isMetaNative) {\n  isMetaNative = true;\n  body = metaNativeParsed;\n} else {\n  body = rawBodyInput;\n}\n\nconst headers = ($json.headers ?? $json?.headers ?? {});\nconst qs = ($json.query || $json.qs || {});\n\nfunction normVersion(v) {\n  const s = (v || '').toString().trim().toLowerCase();\n  if (!s) return 'v1';\n  if (s === '1' || s === 'v1') return 'v1';\n  if (s === '2' || s === 'v2') return 'v2';\n  return 'unknown';\n}\n\nconst headerVer = headers['x-contract-version'] || headers['X-Contract-Version'] || headers['x_contract_version'] || '';\nconst bodyVer = body.contract_version || body.contractVersion || '';\nconst contractVersion = normVersion(headerVer || bodyVer || 'v1');\n\nconst auth = (headers['authorization'] || headers['Authorization'] || '').toString();\nconst bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';\n\nconst headerToken = (\n  headers['x-api-token'] || headers['X-Api-Token'] ||\n  headers['x-webhook-token'] || headers['X-Webhook-Token'] ||\n  ''\n).toString().trim();\n\nconst allowQueryToken = (($env.ALLOW_QUERY_TOKEN || 'false').toString().toLowerCase() === 'true');\nconst queryTokenProvided = !!(qs['token'] || qs['access_token']);\nconst queryToken = allowQueryToken ? (qs['token'] || qs['access_token'] || '') : '';\n\nconst token = (headerToken || bearer || queryToken || '').toString().trim();\nconst queryTokenUsed = !!queryToken && !headerToken && !bearer;\n\nconst shared = ($env.WEBHOOK_SHARED_TOKEN || '').toString().trim();\nconst legacySharedConfigured = !!shared;\nconst legacySharedValid = !!token && legacySharedConfigured && (token === shared);\n\n// Meta/Messenger signature verification (X-Hub-Signature-256) - P0-SEC-03\nconst metaSig = (headers['x-hub-signature-256'] || headers['X-Hub-Signature-256'] || '').toString().trim();\nconst metaSecret = ($env.META_APP_SECRET || '').toString();\nconst metaSigMode = ($env.META_SIGNATURE_REQUIRED || 'off').toString().toLowerCase();\nconst metaSigRequired = (metaSigMode === 'true' || metaSigMode === 'enforce');\nconst metaSigWarn = (metaSigMode === 'warn');\n\nfunction timingSafeEq(a, b) {\n  try {\n    const ba = Buffer.from(String(a));\n    const bb = Buffer.from(String(b));\n    if (ba.length !== bb.length) return false;\n    return crypto.timingSafeEqual(ba, bb);\n  } catch { return false; }\n}\n\nlet metaSigValid = null;\nlet metaSigReason = '';\nif (!metaSig) {\n  metaSigValid = metaSigRequired ? false : null;\n  metaSigReason = 'signature_missing';\n} else if (!metaSecret) {\n  metaSigValid = false;\n  metaSigReason = 'secret_missing';\n} else {\n  const raw = ($json.rawBody && typeof $json.rawBody === 'string') ? $json.rawBody : JSON.stringify(body || {});\n  const expected = 'sha256=' + crypto.createHmac('sha256', metaSecret).update(raw, 'utf8').digest('hex');\n  metaSigValid = timingSafeEq(expected, metaSig);\n  metaSigReason = metaSigValid ? 'ok' : 'invalid';\n}\n\nconst ipRaw = (headers['x-forwarded-for'] || headers['X-Forwarded-For'] || '').toString();\nconst ip = ipRaw.split(',')[0].trim();\n\nconst inboundReceivedAt = new Date().toISOString();\n\n// P1-01: Generate correlation ID for end-to-end tracing\nconst correlationId = (headers['x-correlation-id'] || headers['X-Correlation-Id'] || headers['x-request-id'] || crypto.randomUUID()).toString();\n\n// Hints from body (NEVER trusted)\nconst tenantHint = (body.tenantId || body.tenant_id || body.tenant || '').toString();\nconst restaurantHint = (body.restaurantId || body.restaurant_id || body.restaurant || '').toString();\n\n// Detect canonical envelope\nconst looksLikeV1 = body && typeof body === 'object' && body.provider && (body.msg_id || body.msgId) && (body.from || body.sender || body.sender_id);\nconst looksLikeV2 = body && typeof body === 'object' && body.provider && body.sender && body.message && (body.msg_id || body.msgId);\n\nfunction buildEnvelopeLegacy() {\n  const userId = (body.userId || body.from || body.sender || body.sender_id || 'unknown-user').toString();\n  const msgId = (body.msgId || body.messageId || body.mid || body.message?.id || body.message?.mid || crypto.randomUUID()).toString();\n  const text = (body.text || body.message?.text || body.message?.body || '').toString();\n\n  const buttonId = (body.buttonId || body.interactive?.button_reply?.id || body.message?.buttonId || '').toString();\n  const audioUrl = (body.audioUrl || body.audio?.url || body.message?.audio?.url || '').toString();\n  const audioMime = (body.audio?.mime || body.message?.audio?.mime || 'audio/ogg').toString();\n  const imageUrl = (body.imageUrl || body.image?.url || body.message?.image?.url || '').toString();\n  const imageMime = (body.image?.mime || body.message?.image?.mime || 'image/jpeg').toString();\n\n  let attachments = [];\n  if (audioUrl) attachments.push({type:'audio', url: audioUrl, mime: audioMime});\n  if (imageUrl) attachments.push({type:'image', url: imageUrl, mime: imageMime});\n\n  const locale = (body.locale || body.meta?.locale || '').toString();\n  const timezone = (body.timezone || body.meta?.timezone || '').toString();\n\n  return {\n    contract_version: contractVersion === 'unknown' ? 'v1' : contractVersion,\n    provider: 'msg',\n    msg_id: msgId,\n    from: userId,\n    text: buttonId ? buttonId : text,\n    timestamp: (body.timestamp || body.time || body.meta?.timestamp || inboundReceivedAt).toString(),\n    attachments,\n    meta: {\n      locale: locale || undefined,\n      timezone: timezone || undefined,\n      ip: ip || undefined,\n      user_agent: (headers['user-agent'] || headers['User-Agent'] || '').toString() || undefined\n    },\n    tenant_context: {\n      source: 'untrusted_payload',\n      hints: {\n        tenant_hint: tenantHint || undefined,\n        restaurant_hint: restaurantHint || undefined\n      }\n    }\n  };\n}\n\nfunction buildEnvelopeFromV1(obj) {\n  return {\n    contract_version: 'v1',\n    provider: (obj.provider || 'msg').toString(),\n    msg_id: (obj.msg_id || obj.msgId).toString(),\n    from: (obj.from || obj.sender || obj.sender_id).toString(),\n    text: (obj.text || '').toString(),\n    timestamp: (obj.timestamp || inboundReceivedAt).toString(),\n    attachments: Array.isArray(obj.attachments) ? obj.attachments : [],\n    meta: obj.meta || {},\n    tenant_context: obj.tenant_context || obj.tenantContext || {\n      source: 'untrusted_payload',\n      hints: { tenant_hint: tenantHint || undefined, restaurant_hint: restaurantHint || undefined }\n    }\n  };\n}\n\nfunction buildEnvelopeFromV2(obj) {\n  return {\n    contract_version: 'v2',\n    provider: (obj.provider || 'msg').toString(),\n    msg_id: (obj.msg_id || obj.msgId).toString(),\n    sender: {\n      id: (obj.sender?.id || obj.sender?.from || obj.sender?.userId || '').toString(),\n      display_name: (obj.sender?.display_name || obj.sender?.displayName || '').toString() || undefined\n    },\n    message: {\n      text: (obj.message?.text || '').toString() || undefined,\n      attachments: Array.isArray(obj.message?.attachments) ? obj.message.attachments : []\n    },\n    timestamp: (obj.timestamp || inboundReceivedAt).toString(),\n    meta: obj.meta || {},\n    tenant_context: obj.tenant_context || obj.tenantContext || {\n      source: 'untrusted_payload',\n      hints: { tenant_hint: tenantHint || undefined, restaurant_hint: restaurantHint || undefined }\n    }\n  };\n}\n\nlet envelope;\n\nif (isStatusUpdate) {\n  normalizedVersion = 'v1';\n  envelope = {\n    contract_version: 'v1',\n    provider: 'msg',\n    msg_id: 'status_update',\n    from: 'status_update',\n    text: '',\n    timestamp: inboundReceivedAt,\n    attachments: [],\n    meta: { status_update: true },\n    tenant_context: { source: 'status_update', hints: {} }\n  };\n}\nlet normalizedVersion = contractVersion;\nelse if (normalizedVersion === 'unknown') {\n  envelope = null;\n} else if (normalizedVersion === 'v2') {\n  envelope = buildEnvelopeFromV2(body);\n} else if (normalizedVersion === 'v1' && looksLikeV1 && !looksLikeV2) {\n  envelope = buildEnvelopeFromV1(body);\n} else {\n  normalizedVersion = 'v1';\n  envelope = buildEnvelopeLegacy();\n}\n\n// Validate envelope against schema\nlet isValid = isStatusUpdate ? true : false;\nlet errors = [];\nlet schemaHash = '';\nlet schemaPath = '';\nlet validator = 'ajv';\n\ntry {\n  if (!envelope) throw new Error('unknown_contract_version');\n  if ((envelope.provider || '').toString() !== 'msg') throw new Error('provider_mismatch');\n  const schemasRoot = ($env.SCHEMAS_ROOT || '/opt/resto/schemas').toString();\n  schemaPath = path.join(schemasRoot, 'inbound', `${envelope.contract_version}.json`);\n  const schemaText = fs.readFileSync(schemaPath, 'utf8');\n  schemaHash = crypto.createHash('sha256').update(schemaText).digest('hex');\n  const schema = JSON.parse(schemaText);\n\n  let Ajv;\n  try { Ajv = require('ajv'); } catch (e) { Ajv = null; }\n  if (!Ajv) {\n    validator = 'basic';\n    // Hard fail: schema validation must be available in production\n    throw new Error('ajv_not_available');\n  }\n  const ajv = new Ajv({allErrors:true, strict:false, allowUnionTypes:true});\n  const validate = ajv.compile(schema);\n  isValid = validate(envelope);\n  if (!isValid) {\n    errors = (validate.errors || []).slice(0, 10).map(e => ({path: e.instancePath || '', message: e.message || 'invalid'}));\n  }\n} catch (err) {\n  isValid = false;\n  errors = [{path:'', message: (err && err.message) ? err.message : 'validation_error'}];\n}\n\nconst tokenHash = token ? crypto.createHash('sha256').update(token).digest('hex') : '';\n\n// Build internal canonical message (keep existing fields)\nconst ch = 'messenger';\nconst userId = envelope?.contract_version === 'v2'\n  ? (envelope.sender?.id || '').toString()\n  : (envelope?.from || '').toString();\n\nconst msgId = envelope?.contract_version === 'v2'\n  ? (envelope.msg_id || '').toString()\n  : (envelope?.msg_id || '').toString();\n\nconst text = envelope?.contract_version === 'v2'\n  ? ((envelope.message?.text || '')).toString()\n  : ((envelope?.text || '')).toString();\n\nconst atts = envelope?.contract_version === 'v2'\n  ? (Array.isArray(envelope.message?.attachments) ? envelope.message.attachments : [])\n  : (Array.isArray(envelope.attachments) ? envelope.attachments : []);\n\nconst firstAudio = Array.isArray(atts) ? atts.find(a => a && a.type === 'audio' && a.url) : null;\nconst firstImage = Array.isArray(atts) ? atts.find(a => a && a.type === 'image' && a.url) : null;\n\nlet type = 'text';\nif (firstAudio) type = 'audio';\nif (firstImage) type = 'image';\n\nconst textHash = crypto.createHash('sha256').update((text || '').toString()).digest('hex');\n\nreturn [{\n  json: {\n    channel: ch,\n    _metaParsing: {\n      isMetaNative,\n      isStatusUpdate,\n      rawBodyType: rawBodyInput?.object || 'legacy'\n    },\n    userId: userId || (body.userId || body.from || body.sender || 'unknown-user').toString(),\n    tenantId: '',\n    restaurantId: '',\n    conversationKey: '',\n    roleHint: body.roleHint || 'customer',\n    contract_version: normalizedVersion,\n    inbound_envelope: envelope,\n    metadata: {\n      msgId: msgId || (body.msgId || body.messageId || crypto.randomUUID()).toString(),\n      timestamp: envelope?.timestamp || inboundReceivedAt,\n      ip,\n      userAgent: (headers['user-agent'] || headers['User-Agent'] || '').toString(),\n      testMode: !!body.testMode\n    },\n    message: {\n      type,\n      text: (text || '').toString().trim(),\n      buttonId: '',\n      audio: firstAudio ? { url: firstAudio.url, mime: firstAudio.mime || 'audio/ogg' } : null,\n      image: firstImage ? { url: firstImage.url, mime: firstImage.mime || 'image/jpeg' } : null\n    },\n    tenant_context_hints: {\n      tenant_hint: tenantHint || '',\n      restaurant_hint: restaurantHint || ''\n    },\n    _contract: {\n      version: normalizedVersion,\n      schemaPath,\n      schemaHash,\n      validator,\n      isValid,\n      errors\n    },\n    _timing: {\n      inbound_received_at: inboundReceivedAt,\n      correlation_id: correlationId\n    },\n    _auth: {\n      tokenPresent: !!token,\n      tokenHash,\n      legacySharedConfigured,\n      legacySharedValid,\n      metaSigPresent: !!metaSig,\n      metaSigRequired,\n      metaSigValid,\n      metaSigReason,\n      allowQueryToken,\n      queryTokenProvided,\n      queryTokenUsed,\n      tenantHint,\n      restaurantHint\n    },\n    _sec: {\n      textHash\n    },\n    raw: body\n  }\n}];\n"
      },
      "id": "9271cba1-3265-41cf-8818-9996067fd826",
      "name": "B0 - Parse & Canonicalize",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2150,
        0
      ]
    },
    {
      "parameters": {
        "workflowId": "W0_MODULE_GUARD",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "json": "={{ { module_key: 'channel_messenger', tenant_id: $json.tenantId } }}"
          }
        }
      },
      "id": "module-guard-msg",
      "name": "B0 - Module Guard",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        -1800,
        -150
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.allowed }}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "guard-check-msg",
      "name": "B0 - Guard OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -1600,
        -150
      ]
    },
    {
      "parameters": {
        "responseCode": 403,
        "responseBody": "={{JSON.stringify({error:'gated_access',reason:$json.reason || 'Messenger channel disabled'})}}",
        "options": {}
      },
      "id": "guard-error-msg",
      "name": "RESP - 403 Forbidden",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        -1400,
        150
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._auth.authOk}}",
              "operation": "isTrue"
            },
            {
              "value1": "={{$json._auth.scopeOk}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "9cb91bc4-475a-48ea-828e-11b249b6ba16",
      "name": "B0 - Token OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -1920,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH ins AS (\n      INSERT INTO idempotency_keys (conversation_key, msg_id, channel)\n      VALUES ($1, $2, $3)\n      ON CONFLICT DO NOTHING\n      RETURNING 1\n    )\n    SELECT COALESCE((SELECT 1 FROM ins), 0) AS inserted;",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey, $json.metadata.msgId, $json.channel]}}"
        }
      },
      "id": "1958cc5d-50a6-4fe6-83a8-e3c180d74584",
      "name": "B0 - Idempotency (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1700,
        -120
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "// P1-01: Idempotency check result + Redis key preparation\nconst inserted = Number($json.inserted || 0);\nconst isNew = inserted === 1;\n\n// Prepare Redis key for future migration (see docs/REDIS_SETUP.md)\nconst redisDedupeKey = `ralphe:dedupe:${$json.channel}:${$json.metadata?.msgId || 'unknown'}`;\nconst redisRateLimitKey = `ralphe:rl:${$json.conversationKey || 'unknown'}`;\n\nreturn [{json: {\n  ...$json,\n  _sec: {\n    ...$json._sec,\n    isNew,\n    redisDedupeKey,\n    redisRateLimitKey\n  }\n}}];"
      },
      "id": "59afcbb5-138f-4973-8b01-102e400940ef",
      "name": "B0 - Idempotency Flag",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1480,
        -120
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._sec.isNew}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "8b2849f8-2924-4c25-9ee3-5d500831a9f7",
      "name": "B0 - Is New Msg?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -1260,
        -120
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH ins AS (\n      INSERT INTO inbound_messages (conversation_key, msg_id, channel, message_type, text_hash, meta_json)\n      VALUES ($1, $2, $3, $4, $5, $6::jsonb)\n      ON CONFLICT DO NOTHING\n      RETURNING 1\n    )\n    SELECT COUNT(*)::int AS cnt_30s\n    FROM inbound_messages\n    WHERE conversation_key = $1\n      AND received_at > (now() - interval '30 seconds');",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey, $json.metadata.msgId, $json.channel, $json.message.type, $json._sec.textHash, JSON.stringify({ip:$json.metadata.ip,ua:$json.metadata.userAgent})]}}"
        }
      },
      "id": "75a0e5fd-5425-4312-ac55-ccb90c3da51e",
      "name": "B0 - RateLimit + Log",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1040,
        -240
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const limit = Number($env.RATE_LIMIT_PER_30S || 6);\nconst cnt = Number($json.cnt_30s || 0);\nconst allowed = cnt <= limit;\nreturn [{json: {...$json, _sec: {...$json._sec, rateCnt30s: cnt, rateAllowed: allowed}}}];"
      },
      "id": "03b58b63-20c3-49b2-a648-ba2926dc7516",
      "name": "B0 - RateLimit Flag",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -820,
        -240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._sec.rateAllowed}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "a46a7964-a367-4e07-92a3-bd5892a432a0",
      "name": "B0 - Rate OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -600,
        -240
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT 1 AS quarantined\n    FROM conversation_quarantine\n    WHERE conversation_key=$1 AND active=true\n      AND (expires_at IS NULL OR expires_at > now())\n    LIMIT 1;",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey]}}"
        }
      },
      "id": "e7f57fac-c186-4719-8653-b4b1c9615227",
      "name": "B0 - Quarantine Check",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -380,
        -360
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const q = ($json.quarantined === 1 || $json.quarantined === '1');\nconst notQ = !q;\nreturn [{json: {...$json, _sec: {...$json._sec, notQuarantined: notQ}}}];"
      },
      "id": "0af4f5cd-3d10-4aca-92d4-88212dd41d93",
      "name": "B0 - Quarantine Flag",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -160,
        -360
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._sec.notQuarantined}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "d4cb94e5-813a-4bf6-a891-393b26d436bc",
      "name": "B0 - Not Quarantined?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        60,
        -360
      ]
    },
    {
      "parameters": {
        "workflowId": "={{$env.CORE_WORKFLOW_ID}}",
        "options": {
          "waitTillFinished": false
        }
      },
      "id": "647ba104-87f3-4364-9c9c-8a6a29b52f18",
      "name": "B1 - Execute CORE_AGENT",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        300,
        -360
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "return [{json:{ok:true}}];"
      },
      "id": "2e30cd7e-1326-4bf2-a128-d1bcc7c1e8ed",
      "name": "END - Drop/Done",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH c AS (  SELECT client_id, client_name, tenant_id, restaurant_id, scopes  FROM api_clients  WHERE is_active=true AND token_hash = $1  LIMIT 1) SELECT   (SELECT client_id FROM c) AS client_id,   (SELECT client_name FROM c) AS client_name,   (SELECT tenant_id FROM c) AS tenant_id,   (SELECT restaurant_id FROM c) AS restaurant_id,   COALESCE((SELECT scopes FROM c), '[]'::jsonb) AS scopes,   EXISTS(SELECT 1 FROM c) AS matched;",
        "additionalFields": {
          "queryParams": "={{[$json._auth.tokenHash]}}"
        }
      },
      "id": "a54a2257-f0cb-45c9-a875-37708b5f5a91",
      "name": "B0 - Resolve Client (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        280,
        -120
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst matched = !!e.matched;\nconst legacyOk = !!e._auth?.legacySharedValid;\nconst legacyAllowed = (($env.LEGACY_SHARED_ALLOWED || 'false').toString().toLowerCase() === 'true');\n\nlet tenantId = '';\nlet restaurantId = '';\n// P0-04: Meta signature auth for MSG (Meta doesn't send x-api-token)\nconst metaSigMode = ($env.META_SIGNATURE_REQUIRED || 'off').toString().toLowerCase();\nconst metaSigValid = (e._auth?.metaSigValid === true);\nconst metaAuthEnabled = (metaSigMode === 'warn' || metaSigMode === 'enforce');\n\nlet authMode = 'deny';\nlet scopes = [];\n\nif (matched && e.tenant_id && e.restaurant_id) {\n  tenantId = e.tenant_id.toString();\n  restaurantId = e.restaurant_id.toString();\n  authMode = 'api_client';\n  // scopes may be returned as array or string\n  try { scopes = Array.isArray(e.scopes) ? e.scopes : (typeof e.scopes === 'string' ? JSON.parse(e.scopes) : (e.scopes?.scopes || [])); } catch { scopes = []; }\n} else if (metaAuthEnabled && metaSigValid) {\n  // P0-04: Meta signature auth - valid signature = authenticated\n  tenantId = '00000000-0000-0000-0000-000000000001';\n  restaurantId = '00000000-0000-0000-0000-000000000000';\n  authMode = 'meta_signature';\n  scopes = ['inbound:write'];\n} else if (legacyOk && legacyAllowed) {\n  // Legacy fallback to keep backward compatibility (MVP)\n  tenantId = '00000000-0000-0000-0000-000000000001';\n  restaurantId = '00000000-0000-0000-0000-000000000000';\n  authMode = 'legacy_shared';\n  scopes = ['legacy_shared'];\n}\n\nconst conversationKey = tenantId ? (tenantId + ':' + restaurantId + ':' + e.channel + ':' + e.userId) : '';\nlet authOk = authMode !== 'deny';\n\n// ---- Scopes enforcement (Release-grade)\n// Required scopes are per-endpoint (here: inbound).\nconst requiredScopes = ['inbound:write'];\n\nfunction hasScope(required, granted) {\n  if (!required) return true;\n  const g = new Set((granted || []).map(s => String(s || '').trim()).filter(Boolean));\n  if (g.has(required)) return true;\n  if (g.has('*')) return true;\n  const parts = String(required).split(':');\n  if (parts.length === 2 && g.has(`${parts[0]}:*`)) return true;\n  return false;\n}\n\n// Legacy token can keep inbound compatibility, but MUST NOT be used for admin/internal.\nconst legacyBypass = (authMode === 'legacy_shared') && requiredScopes.some(s => s.startsWith('inbound:'));\nconst scopeOk = authOk && (legacyBypass || requiredScopes.length === 0 || requiredScopes.some(r => hasScope(r, scopes)));\n\nconst endpoint_group = 'inbound';\nconst endpoint_path = '/v1/inbound/messenger';\n\nlet denyReason = authOk ? (scopeOk ? '' : 'SCOPE_DENY') : 'AUTH_DENY';\n\n// Meta signature enforcement (fail-close only if META_SIGNATURE_REQUIRED=enforce)\nconst metaSigRequired = !!e._auth?.metaSigRequired;\nconst metaSigValid = (e._auth?.metaSigValid === true);\nconst metaSigPresent = !!e._auth?.metaSigPresent;\nconst metaSigReason = (e._auth?.metaSigReason || '').toString();\n\n// P0-08: Anti-replay timestamp validation\nconst replayWindowMs = parseInt($env.REPLAY_WINDOW_SECONDS || '300', 10) * 1000;\nconst replayCheckEnabled = (($env.REPLAY_CHECK_ENABLED || 'true').toString().toLowerCase() === 'true');\nlet timestampValid = true;\nlet timestampReason = '';\n\nif (replayCheckEnabled && e.metadata?.timestamp) {\n  const msgTs = e.metadata.timestamp;\n  let msgTime = 0;\n  if (typeof msgTs === 'string' && msgTs.includes('T')) {\n    msgTime = new Date(msgTs).getTime();\n  } else if (typeof msgTs === 'number' || /^\\\\d+$/.test(msgTs)) {\n    const num = Number(msgTs);\n    msgTime = num > 9999999999 ? num : num * 1000;\n  } else {\n    msgTime = new Date(msgTs).getTime();\n  }\n  const now = Date.now();\n  const age = now - msgTime;\n  if (isNaN(msgTime) || msgTime <= 0) {\n    timestampValid = true;\n    timestampReason = 'unparseable';\n  } else if (age > replayWindowMs) {\n    timestampValid = false;\n    timestampReason = 'too_old';\n  } else if (age < -60000) {\n    timestampValid = false;\n    timestampReason = 'future_timestamp';\n  } else {\n    timestampReason = 'ok';\n  }\n}\n\n// hard deny when meta signature is required and invalid/missing\nif (metaSigRequired && !metaSigValid) {\n  denyReason = metaSigPresent ? 'MSG_SIGNATURE_INVALID' : 'MSG_SIGNATURE_MISSING';\n}\n// deny on replay attack\nif (replayCheckEnabled && !timestampValid && timestampReason !== 'unparseable') {\n  denyReason = 'REPLAY_ATTACK_' + timestampReason.toUpperCase();\n}\n// deny legacy token if presented but legacy is not allowed\nif (legacyOk && !legacyAllowed) {\n  denyReason = 'LEGACY_TOKEN_BLOCKED';\n}\n\nconst tenant_context = {\n  tenant_id: tenantId || null,\n  restaurant_id: restaurantId || null,\n  source: authMode === 'api_client' ? 'auth_db' : (authMode === 'meta_signature' ? 'meta_signature' : (authMode === 'legacy_shared' ? 'legacy_shared' : 'untrusted_payload')),\n  client_id: matched ? (e.client_id || null) : null,\n  client_name: matched ? (e.client_name || null) : null,\n  scopes\n};\n\n\nreturn [{\n  json: {\n    ...e,\n    tenantId,\n    restaurantId,\n    conversationKey,\n    tenant_context,\n      _auth: {\n      ...e._auth,\n      authOk,\n      authMode,\n      scopes,\n      requiredScopes,\n      scopeOk,\n      endpoint_group,\n      endpoint_path,\n      denyReason,\n      clientId: matched ? (e.client_id || null) : null,\n      clientName: matched ? (e.client_name || null) : null\n    }\n  }\n}];"
      },
      "id": "0aba4d61-8e1b-4f65-b17b-a02515c8685f",
      "name": "B0 - Apply Auth Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        -120
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO security_events(tenant_id, restaurant_id, conversation_key, channel, user_id, event_type, severity, payload_json) VALUES ($1,$2,$3,$4,$5,$6,'HIGH', jsonb_build_object('token_hash',$7,'ip',$8,'ua',$9,'tenant_hint',$10,'restaurant_hint',$11,'auth_mode',$12,'required_scopes',$13::jsonb,'scopes',$14::jsonb,'endpoint_group',$15,'endpoint_path',$16)) RETURNING 1;",
        "additionalFields": {
          "queryParams": "={{[null, null, null, $json.channel, $json.userId, ($json._auth.denyReason || 'AUTH_DENY'), $json._auth.tokenHash, $json.metadata.ip, $json.metadata.userAgent, $json._auth.tenantHint, $json._auth.restaurantHint, $json._auth.authMode, JSON.stringify($json._auth.requiredScopes || []), JSON.stringify($json._auth.scopes || []), $json._auth.endpoint_group, $json._auth.endpoint_path]}}"
        }
      },
      "id": "ca8bb2e8-270c-4020-ab57-cc94f9ae2c53",
      "name": "B0 - Log Deny (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        520,
        80
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._contract.isValid && !$json._metaParsing?.isStatusUpdate}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "17b88af7-e086-4b06-8074-342d88806d97",
      "name": "B0 - Contract Valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -1900,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO security_events(tenant_id, restaurant_id, conversation_key, channel, user_id, event_type, severity, payload_json) VALUES (NULL,NULL,NULL,$1,$2,'CONTRACT_VALIDATION_FAILED','MEDIUM', jsonb_build_object('contract_version',$3,'schema_hash',$4,'errors',$5,'ip',$6,'ua',$7)) RETURNING 1;",
        "additionalFields": {
          "queryParams": "={{[$json.channel, $json.userId, $json._contract.version, $json._contract.schemaHash, $json._contract.errors, $json.metadata.ip, $json.metadata.userAgent]}}"
        }
      },
      "id": "5ac2d302-2225-4622-8961-992cff038fdc",
      "name": "B0 - Log Contract Reject (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1630,
        180
      ]
    },
    {
      "parameters": {
        "responseCode": 400,
        "responseBody": "={{({error:'invalid_payload', contract_version:$json._contract.version, details:$json._contract.errors})}}",
        "options": {}
      },
      "id": "4c74cf5a-d452-44ed-9da0-a74065cfec46",
      "name": "RESP - 400 Invalid Payload",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        -1350,
        180
      ]
    },
    {
      "parameters": {
        "responseCode": 200,
        "responseBody": "={{JSON.stringify({status:'received',channel:'messenger',msg_id:$json.metadata?.msgId||'unknown',correlation_id:$json._timing?.correlation_id||'unknown'})}}",
        "options": {}
      },
      "id": "876a9722-1f3c-495c-8c1c-2ec1bf61a63a",
      "name": "RESP - 200 OK",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        780,
        0
      ]
    },
    {
      "parameters": {
        "language": "JavaScript",
        "jsCode": "const crypto = require('crypto');\nconst e = $json;\nconst ctx = e.tenant_context || {};\nconst secret = ($env.TENANT_CONTEXT_SECRET || 'fallback-secret-6789').toString();\nconst seal = crypto.createHmac('sha256', secret).update(JSON.stringify(ctx)).digest('hex');\nreturn [{json:{...e, tenant_context_seal: seal}}];\n"
      },
      "id": "50f8c41d-178b-477c-844c-efb5df411a49",
      "name": "B0 - Seal Tenant Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        730,
        -300
      ]
    },
    {
      "parameters": {
        "language": "JavaScript",
        "jsCode": "const crypto = require('crypto');\nconst e = $json;\nconst ctx = e.tenant_context || {};\nconst secret = ($env.TENANT_CONTEXT_SECRET || 'fallback-secret-6789').toString();\nconst seal = crypto.createHmac('sha256', secret).update(JSON.stringify(ctx)).digest('hex');\nif (e.tenant_context_seal && seal !== e.tenant_context_seal) {\n  throw new Error('TENANT_CONTEXT_TAMPERED');\n}\nreturn [{json:e}];\n"
      },
      "id": "3fa7c424-7e2b-49cb-add0-cab976129681",
      "name": "B0 - Verify Tenant Context Seal",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        50,
        -540
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://n8n-main:5678/webhook/v1/internal/audit-write",
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "workflow_name",
              "value": "W3_IN_MSG"
            },
            {
              "name": "workflow_id",
              "value": "W3"
            },
            {
              "name": "execution_id",
              "value": "={{ $executionId }}"
            },
            {
              "name": "channel",
              "value": "messenger"
            },
            {
              "name": "status",
              "value": "started"
            },
            {
              "name": "started_at",
              "value": "={{ $now.toISO() }}"
            },
            {
              "name": "correlation_id",
              "value": "={{ $json._timing?.correlation_id || '' }}"
            }
          ]
        },
        "options": {
          "timeout": 3000
        }
      },
      "id": "audit-inbound-started-msg",
      "name": "AUDIT - Inbound Started",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        -1700,
        -300
      ],
      "continueOnFail": true,
      "alwaysOutputData": false
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://n8n-main:5678/webhook/v1/internal/audit-write",
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "workflow_name",
              "value": "W3_IN_MSG"
            },
            {
              "name": "workflow_id",
              "value": "W3"
            },
            {
              "name": "execution_id",
              "value": "={{ $executionId }}"
            },
            {
              "name": "channel",
              "value": "messenger"
            },
            {
              "name": "status",
              "value": "completed"
            },
            {
              "name": "completed_at",
              "value": "={{ $now.toISO() }}"
            },
            {
              "name": "correlation_id",
              "value": "={{ $json._timing?.correlation_id || '' }}"
            }
          ]
        },
        "options": {
          "timeout": 3000
        }
      },
      "id": "audit-inbound-completed-msg",
      "name": "AUDIT - Inbound Completed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        520,
        -360
      ],
      "continueOnFail": true,
      "alwaysOutputData": false
    }
  ],
  "connections": {
    "IN - Webhook": {
      "main": [
        [
          {
            "node": "B0 - Parse & Canonicalize",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Parse & Canonicalize": {
      "main": [
        [
          {
            "node": "B0 - Contract Valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Token OK?": {
      "main": [
        [
          {
            "node": "B0 - Module Guard",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "B0 - Log Deny (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Module Guard": {
      "main": [
        [
          {
            "node": "B0 - Guard OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Guard OK?": {
      "main": [
        [
          {
            "node": "B0 - Idempotency (DB)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "RESP - 403 Forbidden",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Idempotency (DB)": {
      "main": [
        [
          {
            "node": "B0 - Idempotency Flag",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Idempotency Flag": {
      "main": [
        [
          {
            "node": "B0 - Is New Msg?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Is New Msg?": {
      "main": [
        [
          {
            "node": "B0 - RateLimit + Log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "END - Drop/Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - RateLimit + Log": {
      "main": [
        [
          {
            "node": "B0 - RateLimit Flag",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - RateLimit Flag": {
      "main": [
        [
          {
            "node": "B0 - Rate OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Rate OK?": {
      "main": [
        [
          {
            "node": "B0 - Quarantine Check",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "END - Drop/Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Quarantine Check": {
      "main": [
        [
          {
            "node": "B0 - Quarantine Flag",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Quarantine Flag": {
      "main": [
        [
          {
            "node": "B0 - Not Quarantined?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Not Quarantined?": {
      "main": [
        [
          {
            "node": "B0 - Verify Tenant Context Seal",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "END - Drop/Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B1 - Execute CORE_AGENT": {
      "main": [
        [
          {
            "node": "AUDIT - Inbound Completed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AUDIT - Inbound Completed": {
      "main": [
        [
          {
            "node": "END - Drop/Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Resolve Client (DB)": {
      "main": [
        [
          {
            "node": "B0 - Apply Auth Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Apply Auth Context": {
      "main": [
        [
          {
            "node": "B0 - Seal Tenant Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Log Deny (DB)": {
      "main": [
        [
          {
            "node": "END - Drop/Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Contract Valid?": {
      "main": [
        [
          {
            "node": "B0 - Resolve Client (DB)",
            "type": "main",
            "index": 0
          },
          {
            "node": "AUDIT - Inbound Started",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "B0 - Log Contract Reject (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Log Contract Reject (DB)": {
      "main": [
        [
          {
            "node": "RESP - 400 Invalid Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RESP - 400 Invalid Payload": {
      "main": [
        []
      ]
    },
    "END - Drop/Done": {
      "main": [
        [
          {
            "node": "RESP - 200 OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RESP - 200 OK": {
      "main": [
        []
      ]
    },
    "B0 - Seal Tenant Context": {
      "main": [
        [
          {
            "node": "B0 - Token OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Verify Tenant Context Seal": {
      "main": [
        [
          {
            "node": "B1 - Execute CORE_AGENT",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}