Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions app/components/token/ApiUrlsCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<template>
<UCard>
<template #header>
<button type="button" class="w-full flex items-center justify-between text-left" @click="isOpen = !isOpen">
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
API Endpoints
</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
Webhook and automation URLs
</span>
</div>
<UIcon :name="isOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="h-5 w-5 text-gray-500 dark:text-gray-400" />
</button>
</template>

<div v-if="isOpen" class="flex flex-col gap-4 p-4 border-t border-gray-200 dark:border-gray-700">
<!-- Payload URL -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Webhook URL
</label>
<div class="flex gap-2">
<UInput :model-value="payloadUrl" readonly size="md" class="flex-1 font-mono text-xs" />
<UTooltip :text="copyPayloadState === 'copied' ? 'Copied!' : 'Copy URL'">
<UButton
:icon="copyPayloadState === 'copied' ? 'i-lucide-check' : 'i-lucide-copy'"
color="neutral"
variant="soft"
@click="handleCopyPayload" />
</UTooltip>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Use this URL to capture requests.
</p>
</div>

<!-- View API URL -->
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Automation API URL
</label>
<div class="flex gap-2">
<UInput :model-value="viewUrl" readonly size="md" class="flex-1 font-mono text-xs" />
<UTooltip :text="copyViewState === 'copied' ? 'Copied!' : 'Copy URL'">
<UButton
:icon="copyViewState === 'copied' ? 'i-lucide-check' : 'i-lucide-copy'"
color="neutral"
variant="soft"
@click="handleCopyView" />
</UTooltip>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Read-only API for automation/LLMs. Returns all requests with bodies in JSON format.
</p>
</div>
</div>
</UCard>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { notify } from '~/composables/useNotificationBridge'
import { useTokensStore } from '~/stores/tokens'
import { copyText } from '~/utils'

const props = defineProps<{ tokenId: string }>()

const tokensStore = useTokensStore()
const { data: token } = tokensStore.useToken(computed(() => props.tokenId))

const isOpen = usePersistedState('api-urls-open', false)
const copyPayloadState = ref<'idle' | 'copied'>('idle')
const copyViewState = ref<'idle' | 'copied'>('idle')

const origin = computed(() => typeof window !== 'undefined' ? window.location.origin : '')

const payloadUrl = computed(() => {
const friendlyId = token.value?.friendlyId || ''
return `${origin.value}/api/payload/${friendlyId}`
})

const viewUrl = computed(() => {
const friendlyId = token.value?.friendlyId || ''
return `${origin.value}/api/view/${friendlyId}?secret=${props.tokenId}`
})

const handleCopyPayload = async () => {
try {
await copyText(payloadUrl.value)
copyPayloadState.value = 'copied'
notify({ title: 'Webhook URL copied', description: payloadUrl.value, variant: 'success' })
setTimeout(() => copyPayloadState.value = 'idle', 1200)
} catch (error) {
console.error('Failed to copy URL:', error)
notify({ title: 'Failed to copy URL', variant: 'error' })
}
}

const handleCopyView = async () => {
try {
await copyText(viewUrl.value)
copyViewState.value = 'copied'
notify({ title: 'API URL copied', description: viewUrl.value, variant: 'success' })
setTimeout(() => copyViewState.value = 'idle', 1200)
} catch (error) {
console.error('Failed to copy URL:', error)
notify({ title: 'Failed to copy URL', variant: 'error' })
}
}
</script>
2 changes: 2 additions & 0 deletions app/pages/token/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
</div>
</div>
<div class="grid gap-6 px-6 pb-6 lg:p-6">
<ApiUrlsCard :token-id="tokenId" />
<ResponseSettingsCard :token-id="tokenId" />
<RawRequestCard :request="selectedRequest" :request-number="selectedRequestNumber" :token-id="tokenId" />
<RequestDetailsCard :request="selectedRequest" :request-number="selectedRequestNumber"
Expand Down Expand Up @@ -59,6 +60,7 @@ import { useSSE } from '~/composables/useSSE'
import type { SSEEventPayload, RequestSummary } from '~~/shared/types'
import { notify } from '~/composables/useNotificationBridge'
import RequestSidebar from '~/components/RequestSidebar.vue'
import ApiUrlsCard from '~/components/token/ApiUrlsCard.vue'
import ResponseSettingsCard from '~/components/token/ResponseSettingsCard.vue'
import RequestDetailsCard from '~/components/token/RequestDetailsCard.vue'
import RawRequestCard from '~/components/token/RawRequestCard.vue'
Expand Down
159 changes: 159 additions & 0 deletions server/api/view/[shortId].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { defineEventHandler, getQuery, createError, type H3Event, type EventHandlerRequest } from 'h3'
import { useDatabase } from '~~/server/lib/db'
import { isUUID } from '~~/server/lib/utils'
import type { Request } from '~~/shared/types'

/**
* LLM-friendly request data format
*/
interface LLMRequest {
id: string
method: string
url: string
headers: Record<string, string>
contentType: string
contentLength: number
isBinary: boolean
body: string | null
clientIp: string
remoteIp: string
createdAt: string
}

/**
* LLM-friendly API response
*/
interface LLMResponse {
token: {
id: string
friendlyId: string | null
createdAt: string
payloadUrl: string
}
requests: LLMRequest[]
total: number
}

/**
* Convert Request to LLM-friendly format with body content
*/
const formatRequestForLLM = async (request: Request, db: ReturnType<typeof useDatabase>): Promise<LLMRequest> => {
let parsedHeaders: Record<string, string> = {}
try {
parsedHeaders = JSON.parse(request.headers)
} catch {
parsedHeaders = {}
}

let bodyContent: string | null = null
if (request.bodyPath && request.contentLength > 0) {
if (request.isBinary) {
bodyContent = '[Binary data not included]'
} else {
const bodyBuffer = await db.requests.getBody(request.sessionId, request.tokenId, request.id)
if (bodyBuffer) {
try {
bodyContent = new TextDecoder('utf-8').decode(bodyBuffer)
} catch {
bodyContent = '[Unable to decode body as text]'
}
}
}
}

return {
id: request.id,
method: request.method,
url: request.url,
headers: parsedHeaders,
contentType: request.contentType,
contentLength: request.contentLength,
isBinary: request.isBinary,
body: bodyContent,
clientIp: request.clientIp,
remoteIp: request.remoteIp,
createdAt: request.createdAt.toISOString(),
}
}

/**
* API endpoint for automation/LLM access to token requests
*
* URL: /api/view/{friendlyId}?secret={tokenUUID}
*
* - friendlyId: The 8-character short ID of the token
* - secret: Must be the full token UUID (token.id) for authentication
*
* Returns LLM-friendly JSON with all requests and their bodies
*/
export default defineEventHandler(async (event: H3Event<EventHandlerRequest>) => {
const method = event.node.req.method?.toUpperCase() || 'GET'

if ('GET' !== method) {
throw createError({ statusCode: 405, message: 'Method not allowed' })
}

type EventContextParams = { params?: Record<string, string> }
const ctx = (event.context as unknown as EventContextParams) || {}
const params = ctx.params || {}
const shortId = params.shortId

if (!shortId) {
throw createError({ statusCode: 400, message: 'Short ID is required' })
}

const query = getQuery(event)
const secret = query.secret as string | undefined

if (!secret) {
throw createError({ statusCode: 401, message: 'Secret parameter is required' })
}

// Secret must be a valid UUID
if (!isUUID(secret)) {
throw createError({ statusCode: 401, message: 'Invalid secret format' })
}

const db = useDatabase()

// Only accept friendlyId (short ID) in the URL path
const token = await db.tokens.getByFriendlyId(shortId)
if (!token) {
throw createError({ statusCode: 404, message: 'Token not found' })
}

// Verify that the secret matches the token ID
if (token.id !== secret) {
throw createError({ statusCode: 403, message: 'Invalid secret' })
}

// Fetch all requests for this token
const requests = await db.requests.list(token.sessionId, token.id)

// Format requests for LLM consumption
const formattedRequests: LLMRequest[] = []
for (const request of requests) {
const formatted = await formatRequestForLLM(request, db)
formattedRequests.push(formatted)
}

// Build payload URL for webhook ingestion
// Try to get from request headers, fallback to config or localhost
const host = event.node.req.headers.host || 'localhost:3000'
const protocol = event.node.req.headers['x-forwarded-proto'] || 'http'
const baseUrl = `${protocol}://${host}`
const payloadUrl = `${baseUrl}/api/payload/${token.friendlyId || token.id}`

const response: LLMResponse = {
token: {
id: token.id,
friendlyId: token.friendlyId,
createdAt: token.createdAt.toISOString(),
payloadUrl,
},
requests: formattedRequests,
total: formattedRequests.length,
}

return response
})
Loading