← Files DescopeARCHIVED FILE

parse-flow.mjs

9.31 KB · Oct 2, 2026 · 00:09 UTC

↓ Download file

#!/usr/bin/env node
// Extract the BYOS-relevant shape of a Descope flow JSON export.
// Prints: every screen task, its inputs (the real form keys), its next
// rules (interactionId → target task), its UI node summary (input `name`
// attrs — what BYOS actually sends), and subflow invocations.
//
// Usage: node parse-flow.mjs <flow.json> [more-flows.json ...]
//
// Output is plain text, intended to be pasted into the BYOS screen-map
// comment header. The fields you need for BYOS components:
//   - screen name      (map key)
//   - interactionId    (what you pass to state.next)
//   - input node name  (the form key — NOT allInputKeys)
//   - contextKeys      (what the screen reads from state.context)

import { readFileSync } from 'node:fs'
import path from 'node:path'

function summarizeNode(node) {
  const comp = node.type?.resolvedName || '?'
  const p = node.props || {}
  const bits = [comp]
  if (p.name) bits.push(`name="${p.name}"`)
  if (p.ctxKey) bits.push(`ctxKey="${p.ctxKey}"`) // prefill from state.context[ctxKey]
  if (p.label) bits.push(`label="${p.label}"`)
  if (p.type) bits.push(`t="${p.type}"`)
  if (typeof p.children === 'string') {
    bits.push(`text="${p.children.slice(0, 60).replace(/\n/g, ' ')}"`)
  }
  if (p.placeholder) bits.push(`ph="${p.placeholder}"`)
  if (p.required) bits.push('req')
  if (p['data-descope-provider']) bits.push(`prov=${p['data-descope-provider']}`)
  return bits.join(' ')
}

function isWebauthnTask(task) {
  const action = task?.action?.value || task?.actionType || ''
  const name = (task?.name || '').toLowerCase()
  return /webauthn|passkey/i.test(action) || /webauthn|passkey/.test(name)
}

function findLoggedInTaskIds(tasks) {
  const ids = new Set()
  for (const [k, t] of Object.entries(tasks)) {
    const action = t?.action?.value || t?.actionType || ''
    if (/logged-?in/i.test(action) || /logged-?in/i.test(t?.name || '')) ids.add(k)
  }
  return ids
}

// A subflow loader is "post-auth" if any path from start can reach it only
// after a logged-in action. Approximation: if any rule pointing AT this
// subflow's invoker task originates from a task whose ancestors include a
// logged-in task. Cheap heuristic: subflow loader's task key is reachable
// only via tasks whose action mentions logged-in upstream. We do a simpler
// signal: scan all next.rules; if a logged-in task routes (transitively)
// into the subflow loader, mark post-auth.
function isPostAuthSubflow(loaderKey, tasks, loggedInIds) {
  if (loggedInIds.size === 0) return false
  // BFS from each logged-in task, see if we reach loaderKey.
  const seen = new Set()
  const queue = [...loggedInIds]
  while (queue.length) {
    const cur = queue.shift()
    if (seen.has(cur)) continue
    seen.add(cur)
    if (cur === loaderKey) return true
    const rules = tasks[cur]?.next?.rules ?? []
    for (const r of rules) if (r.taskId && !seen.has(r.taskId)) queue.push(r.taskId)
  }
  return false
}

function printFlow(filePath) {
  const flow = JSON.parse(readFileSync(filePath, 'utf8'))
  const flowId = flow.flowId ?? '(unknown)'
  const startTask = flow.contents?.startTask ?? '(unknown)'

  console.log('\n═══════════════════════════════════════════════════')
  console.log(`  ${path.basename(filePath)}   flowId=${flowId}`)
  console.log(`  startTask: ${startTask}`)
  console.log('═══════════════════════════════════════════════════')

  const screensArr = flow.screens ?? []
  const byScreenId = {}
  for (const s of screensArr) if (s.screenId) byScreenId[s.screenId] = s

  const tasks = flow.contents?.tasks ?? {}

  // Shared interactions (bubble up from subflows). Signal: screen.interaction
  // is NOT defined on a task but IS in metadata.sharedInteractions.
  const shared = flow.metadata?.sharedInteractions ?? []
  if (shared.length) {
    console.log('\nShared interactions (bubble to parent flow):')
    for (const s of shared) {
      console.log(`  ${s.id}${s.label ? `  "${s.label}"` : ''}${s.type ? `  [${s.type}]` : ''}`)
    }
  }

  // Screens
  for (const [taskKey, task] of Object.entries(tasks)) {
    if (!task.screenId) continue
    const scr = byScreenId[task.screenId]
    console.log(`\n── screen task ${taskKey}: "${task.name}"`)
    console.log(`   allInputKeys: ${JSON.stringify(task.allInputKeys ?? [])}`)
    console.log(`   contextKeys:  ${JSON.stringify(task.contextKeys ?? [])}`)
    if (task.inputsMetadata) {
      for (const [k, v] of Object.entries(task.inputsMetadata)) {
        console.log(`   inputsMetadata[${k}]: key="${v.key}" display="${v.displayName}"`)
      }
    }
    if (task.componentsConditions?.length) {
      console.log(`   componentsConditions: ${task.componentsConditions.length} rule(s) (see hosted-screen hide/show logic; mirror in BYOS)`)
    }
    console.log(`   next rules (interactionId → taskId):`)
    for (const r of (task.next?.rules ?? [])) {
      const target = tasks[r.taskId]
      console.log(`     • ${r.interactionId}  →  task ${r.taskId}  "${target?.name ?? '?'}"`)
    }
    if (scr) {
      console.log(`   UI nodes (action-ish components; form key = node's "name" prop):`)
      for (const [id, node] of Object.entries(scr.contents || {})) {
        if (node.type?.resolvedName === 'Container') continue
        console.log(`     [${id.slice(0, 14).padEnd(14)}] ${summarizeNode(node)}`)
      }
    }
  }

  // WebAuthn / passkey action tasks — BYOS interactions that route here
  // are SDK-handled (the SDK runs navigator.credentials.*). BYOS code must
  // NOT call WebAuthn APIs itself — just fire state.next(interactionId).
  const webauthnTasks = []
  for (const [k, t] of Object.entries(tasks)) {
    if (isWebauthnTask(t)) webauthnTasks.push({ key: k, name: t.name, action: t?.action?.value })
  }
  if (webauthnTasks.length) {
    console.log('\n── WebAuthn action tasks (SDK runs ceremony — BYOS just fires interaction):')
    for (const w of webauthnTasks) {
      console.log(`   task ${w.key} "${w.name}"  action="${w.action ?? '?'}"`)
    }
  }

  // Subflow loaders (flagged post-auth if reachable from a logged-in task)
  const loggedInIds = findLoggedInTaskIds(tasks)
  const subflows = []
  for (const [taskKey, task] of Object.entries(tasks)) {
    if (task.arguments?.flowId?.value) {
      subflows.push({
        key: taskKey,
        name: task.name,
        flowId: task.arguments.flowId.value,
        postAuth: isPostAuthSubflow(taskKey, tasks, loggedInIds),
      })
    }
  }
  if (subflows.length) {
    console.log('\n── Subflow invocations (export these flows too!):')
    for (const s of subflows) {
      const tag = s.postAuth ? '  ⚠️ POST-AUTH PROMOTION (runs after logged-in)' : ''
      console.log(`   task ${s.key} "${s.name}" → loads "${s.flowId}"${tag}`)
    }
  }

  // Screen-name collisions within this flow (detection; cross-flow collisions
  // need multi-file input — we print per-flow and the caller merges).
  const screenNames = {}
  for (const [taskKey, task] of Object.entries(tasks)) {
    if (!task.screenId) continue
    if (!screenNames[task.name]) screenNames[task.name] = []
    screenNames[task.name].push(taskKey)
  }
  const collisions = Object.entries(screenNames).filter(([, keys]) => keys.length > 1)
  if (collisions.length) {
    console.log('\n⚠️  Screen-name collisions in this flow (BYOS needs a router):')
    for (const [name, keys] of collisions) {
      console.log(`   "${name}" → tasks ${keys.join(', ')}`)
      console.log(`     → pick a context.form.* key that differs between these tasks,`)
      console.log(`       write a <${name.replace(/\W/g, '')}Router> that dispatches.`)
    }
  }
}

function main() {
  const files = process.argv.slice(2)
  if (files.length === 0) {
    console.error('Usage: node parse-flow.mjs <flow.json> [more.json ...]')
    process.exit(1)
  }
  for (const f of files) printFlow(f)

  // Cross-flow collision detection
  if (files.length > 1) {
    const allNames = {}
    for (const f of files) {
      const flow = JSON.parse(readFileSync(f, 'utf8'))
      for (const [k, t] of Object.entries(flow.contents?.tasks ?? {})) {
        if (!t.screenId) continue
        if (!allNames[t.name]) allNames[t.name] = []
        allNames[t.name].push({ file: path.basename(f), task: k })
      }
    }
    const crossCollisions = Object.entries(allNames).filter(([, refs]) => {
      const files = new Set(refs.map((r) => r.file))
      return files.size > 1
    })
    if (crossCollisions.length) {
      console.log('\n═══════════════════════════════════════════════════')
      console.log('  Cross-flow screen-name collisions (same BYOS screen')
      console.log('  renders for multiple tasks across flows):')
      console.log('═══════════════════════════════════════════════════')
      for (const [name, refs] of crossCollisions) {
        console.log(`  "${name}":`)
        for (const r of refs) console.log(`    ${r.file} task ${r.task}`)
        console.log('    → one BYOS component handles all. Use context/form')
        console.log('      heuristic to enable/disable flow-specific buttons.')
      }
    }
  }
}

main()

SHA-256: 8c770590e9bae236730c9b61b5d5d5f03d9cd7950b55173665d91545feeb6965