feat: hoppscotch agent and agent interceptor (#4396)
Co-authored-by: CuriousCorrelation <CuriousCorrelation@gmail.com> Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
23
packages/hoppscotch-common/src/components.d.ts
vendored
23
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -1,11 +1,11 @@
|
||||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
|
||||
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
|
||||
@@ -174,7 +174,6 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||
@@ -184,10 +183,8 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||
IconLucideX: typeof import('~icons/lucide/x')['default']
|
||||
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
|
||||
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
|
||||
@@ -196,6 +193,10 @@ declare module '@vue/runtime-core' {
|
||||
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
|
||||
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
|
||||
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
|
||||
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
|
||||
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
|
||||
InterceptorsAgentRegistrationModal: typeof import('./components/interceptors/agent/RegistrationModal.vue')['default']
|
||||
InterceptorsAgentRootExt: typeof import('./components/interceptors/agent/RootExt.vue')['default']
|
||||
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
|
||||
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
|
||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||
@@ -209,17 +210,14 @@ declare module '@vue/runtime-core' {
|
||||
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
|
||||
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
|
||||
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
|
||||
ModalsNativeCACertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeCACertificates.vue')['default']
|
||||
ModalsNativeClientCertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertificates.vue')['default']
|
||||
ModalsNativeClientCertsAdd: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertsAdd.vue')['default']
|
||||
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
|
||||
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
|
||||
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
|
||||
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
|
||||
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
|
||||
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
|
||||
SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
|
||||
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
|
||||
SettingsNativeInterceptor: typeof import('./../../hoppscotch-selfhost-desktop/src/components/settings/NativeInterceptor.vue')['default']
|
||||
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
|
||||
Share: typeof import('./components/share/index.vue')['default']
|
||||
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
|
||||
@@ -246,5 +244,4 @@ declare module '@vue/runtime-core' {
|
||||
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
|
||||
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('agent.client_certs')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<ul
|
||||
v-if="certificateMap.size > 0"
|
||||
class="mx-4 border border-dividerDark rounded"
|
||||
>
|
||||
<li
|
||||
v-for="([domain, certificate], index) in certificateMap"
|
||||
:key="domain"
|
||||
class="flex border-dividerDark px-2 items-center justify-between"
|
||||
:class="{ 'border-t border-dividerDark': index !== 0 }"
|
||||
>
|
||||
<div class="flex space-x-2">
|
||||
<div class="truncate">
|
||||
{{ domain }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="text-secondaryLight mr-2">
|
||||
{{ "PEMCert" in certificate.cert ? "PEM" : "PFX/PKCS12" }}
|
||||
</div>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||
:title="
|
||||
certificate.enabled
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
"
|
||||
color="green"
|
||||
@click="toggleEntryEnabled(domain)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
:title="t('action.remove')"
|
||||
color="red"
|
||||
@click="deleteEntry(domain)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<HoppButtonSecondary
|
||||
class="mx-4"
|
||||
:icon="IconPlus"
|
||||
:label="t('agent.add_cert_file')"
|
||||
filled
|
||||
outline
|
||||
@click="showAddModal = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary :label="t('action.save')" @click="save" />
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
filled
|
||||
outline
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
|
||||
<InterceptorsAgentModalNativeClientCertsAdd
|
||||
:show="showAddModal"
|
||||
:existing-domains="Array.from(certificateMap.keys())"
|
||||
@hide-modal="showAddModal = false"
|
||||
@save="saveCertificate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useService } from "dioc/vue"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import {
|
||||
ClientCertificateEntry,
|
||||
AgentInterceptorService,
|
||||
} from "~/platform/std/interceptors/agent"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const nativeInterceptorService = useService(AgentInterceptorService)
|
||||
|
||||
const certificateMap = ref(new Map<string, ClientCertificateEntry>())
|
||||
|
||||
const showAddModal = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
certificateMap.value = cloneDeep(
|
||||
nativeInterceptorService.clientCertificates.value
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function save() {
|
||||
nativeInterceptorService.clientCertificates.value = cloneDeep(
|
||||
certificateMap.value
|
||||
)
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
function saveCertificate(cert: ClientCertificateEntry) {
|
||||
certificateMap.value.set(cert.domain, cert)
|
||||
}
|
||||
|
||||
function toggleEntryEnabled(domain: string) {
|
||||
const certificate = certificateMap.value.get(domain)
|
||||
|
||||
if (certificate) {
|
||||
certificateMap.value.set(domain, {
|
||||
...certificate,
|
||||
enabled: !certificate.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEntry(domain: string) {
|
||||
certificateMap.value.delete(domain)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('agent.add_client_cert')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<HoppSmartInput
|
||||
v-model="domain"
|
||||
:autofocus="false"
|
||||
styles="flex-1"
|
||||
placeholder=" "
|
||||
:label="t('agent.domain')"
|
||||
input-styles="input floating-input"
|
||||
/>
|
||||
|
||||
<HoppSmartTabs v-model="selectedTab">
|
||||
<HoppSmartTab :id="'pem'" :label="'PEM'">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> {{ t("agent.cert") }} </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pemCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pemCert?.type === 'loading'"
|
||||
:label="
|
||||
pemCert?.type === 'loaded'
|
||||
? pemCert.filename
|
||||
: t('agent.add_cert_file')
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pem_cert')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> {{ t("agent.key") }} </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pemKey?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pemKey?.type === 'loading'"
|
||||
:label="
|
||||
pemKey?.type === 'loaded'
|
||||
? pemKey.filename
|
||||
: t('agent.add_key_file')
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pem_key')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
|
||||
<HoppSmartTab :id="'pfx'" :label="t('agent.pfx_or_pkcs')">
|
||||
<div class="p-4 space-y-6">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> {{ t("agent.pfx_or_pkcs_file") }} </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pfxCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pfxCert?.type === 'loading'"
|
||||
:label="
|
||||
pfxCert?.type === 'loaded'
|
||||
? pfxCert.filename
|
||||
: t('agent.add_pfx_or_pkcs_file')
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pfx_cert')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border border-divider rounded">
|
||||
<HoppSmartInput
|
||||
v-model="pfxPassword"
|
||||
:type="showPfxPassword ? 'text' : 'password'"
|
||||
:label="t('authorization.password')"
|
||||
input-styles="floating-input !border-0 "
|
||||
:placeholder="' '"
|
||||
>
|
||||
<template #button>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
showPfxPassword
|
||||
? t('hide.password')
|
||||
: t('show.password')
|
||||
"
|
||||
:icon="showPfxPassword ? IconEye : IconEyeOff"
|
||||
@click="showPfxPassword = !showPfxPassword"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartInput>
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:disabled="!isValidCertificate || anyFileSelectorIsLoading"
|
||||
@click="save"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
filled
|
||||
outline
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconEyeOff from "~icons/lucide/eye-off"
|
||||
import IconEye from "~icons/lucide/eye"
|
||||
import IconFile from "~icons/lucide/file"
|
||||
import { ref, watch, computed } from "vue"
|
||||
import { useFileDialog } from "@vueuse/core"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { ClientCertificateEntry } from "~/platform/std/interceptors/agent"
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
existingDomains: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "save", certificate: ClientCertificateEntry): void
|
||||
}>()
|
||||
|
||||
type FileSelectorState =
|
||||
| null
|
||||
| { type: "loading" }
|
||||
| { type: "loaded"; filename: string; data: Uint8Array }
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const domain = ref("")
|
||||
|
||||
const pemCert = ref<FileSelectorState>(null)
|
||||
const pemKey = ref<FileSelectorState>(null)
|
||||
|
||||
const pfxCert = ref<FileSelectorState>(null)
|
||||
|
||||
const pfxPassword = ref("")
|
||||
const showPfxPassword = ref(false)
|
||||
|
||||
const anyFileSelectorIsLoading = computed(
|
||||
() =>
|
||||
pemCert.value?.type === "loading" ||
|
||||
pemKey.value?.type === "loading" ||
|
||||
pfxCert.value?.type === "loading"
|
||||
)
|
||||
|
||||
const currentlyPickingFile = ref<null | "pem_cert" | "pem_key" | "pfx_cert">(
|
||||
null
|
||||
)
|
||||
|
||||
const selectedTab = ref<"pem" | "pfx">("pem")
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
|
||||
currentlyPickingFile.value = null
|
||||
|
||||
domain.value = ""
|
||||
pemCert.value = null
|
||||
pemKey.value = null
|
||||
pfxCert.value = null
|
||||
pfxPassword.value = ""
|
||||
showPfxPassword.value = false
|
||||
selectedTab.value = "pem"
|
||||
}
|
||||
)
|
||||
|
||||
const certificate = computed<ClientCertificateEntry | null>(() => {
|
||||
if (selectedTab.value === "pem") {
|
||||
if (pemCert.value?.type === "loaded" && pemKey.value?.type === "loaded") {
|
||||
return <ClientCertificateEntry>{
|
||||
domain: domain.value,
|
||||
enabled: true,
|
||||
cert: {
|
||||
PEMCert: {
|
||||
certificate_filename: pemCert.value.filename,
|
||||
certificate_pem: pemCert.value.data,
|
||||
|
||||
key_filename: pemKey.value.filename,
|
||||
key_pem: pemKey.value.data,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pfxCert.value?.type === "loaded") {
|
||||
return <ClientCertificateEntry>{
|
||||
domain: domain.value.trim(),
|
||||
enabled: true,
|
||||
cert: {
|
||||
PFXCert: {
|
||||
certificate_filename: pfxCert.value.filename,
|
||||
certificate_pfx: pfxCert.value.data,
|
||||
password: pfxPassword.value,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isValidCertificate = computed(() => {
|
||||
if (certificate.value === null) return false
|
||||
|
||||
if (props.existingDomains.includes(certificate.value.domain)) {
|
||||
toast.error("A certificate for this domain already exists")
|
||||
return false
|
||||
}
|
||||
|
||||
return ClientCertificateEntry.safeParse(certificate.value).success
|
||||
})
|
||||
|
||||
const {
|
||||
open: openFileDialog,
|
||||
reset: resetFilePicker,
|
||||
onChange: onFilePickerChange,
|
||||
} = useFileDialog({
|
||||
reset: true,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
onFilePickerChange(async (files) => {
|
||||
if (!files) return
|
||||
|
||||
const file = files.item(0)
|
||||
|
||||
if (!file) return
|
||||
|
||||
if (currentlyPickingFile.value === "pem_cert") {
|
||||
pemCert.value = { type: "loading" }
|
||||
} else if (currentlyPickingFile.value === "pem_key") {
|
||||
pemKey.value = { type: "loading" }
|
||||
} else if (currentlyPickingFile.value === "pfx_cert") {
|
||||
pfxCert.value = { type: "loading" }
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
|
||||
if (currentlyPickingFile.value === "pem_cert") {
|
||||
pemCert.value = { type: "loaded", filename: file.name, data }
|
||||
} else if (currentlyPickingFile.value === "pem_key") {
|
||||
pemKey.value = { type: "loaded", filename: file.name, data }
|
||||
} else if (currentlyPickingFile.value === "pfx_cert") {
|
||||
pfxCert.value = { type: "loaded", filename: file.name, data }
|
||||
}
|
||||
|
||||
currentlyPickingFile.value = null
|
||||
|
||||
resetFilePicker()
|
||||
})
|
||||
|
||||
function openFilePicker(type: "pem_cert" | "pem_key" | "pfx_cert") {
|
||||
currentlyPickingFile.value = type
|
||||
|
||||
openFileDialog()
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (certificate.value) {
|
||||
emit("save", certificate.value)
|
||||
emit("hide-modal")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<!-- TODO: i18n -->
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
styles="sm:max-w-md"
|
||||
:title="modalTitle"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<p v-if="status === 'agent_not_running'" class="text-secondaryLight">
|
||||
{{ t("agent.not_running") }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="status === 'registration_required'">
|
||||
<p
|
||||
v-if="registrationStatus === 'initial'"
|
||||
class="text-secondaryLight"
|
||||
>
|
||||
{{ t("agent.registration_instruction") }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="registrationStatus === 'otp_required'">
|
||||
<p class="text-secondaryLight">
|
||||
{{ t("agent.enter_otp_instruction") }}
|
||||
</p>
|
||||
|
||||
<HoppSmartInput
|
||||
v-model="userEnteredOTP"
|
||||
placeholder=" "
|
||||
:label="t('agent.otp_label')"
|
||||
input-styles="input floating-input"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else-if="isRegistrationLoading"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<HoppSmartSpinner />
|
||||
|
||||
<p class="text-secondaryLight">{{ t("agent.processing") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-start flex-1">
|
||||
<HoppButtonPrimary
|
||||
:label="primaryButtonLabel"
|
||||
:loading="isRegistrationLoading"
|
||||
@click="primaryActionHandler"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
class="ml-2"
|
||||
filled
|
||||
outline
|
||||
@click="hideModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
const userEnteredOTP = ref("")
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
status: "agent_not_running" | "registration_required" | "hidden"
|
||||
registrationStatus: "initial" | "otp_required" | "loading"
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "register"): void
|
||||
(e: "verify", otp: string): void
|
||||
(e: "retry-connection"): void
|
||||
}>()
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
switch (props.status) {
|
||||
case "agent_not_running":
|
||||
return t("agent.not_running_title")
|
||||
case "registration_required":
|
||||
return t("agent.registration_title")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
})
|
||||
|
||||
const isRegistrationLoading = computed(
|
||||
() => props.registrationStatus === "loading"
|
||||
)
|
||||
|
||||
const primaryButtonLabel = computed(() => {
|
||||
if (isRegistrationLoading.value) {
|
||||
return t("state.loading")
|
||||
}
|
||||
|
||||
if (props.status === "agent_not_running") {
|
||||
return t("action.retry")
|
||||
}
|
||||
|
||||
if (props.status === "registration_required") {
|
||||
if (props.registrationStatus === "initial") {
|
||||
return t("action.register")
|
||||
}
|
||||
|
||||
if (props.registrationStatus === "otp_required") {
|
||||
return t("action.verify")
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
})
|
||||
|
||||
const primaryActionHandler = () => {
|
||||
if (props.status === "agent_not_running") {
|
||||
return emit("retry-connection")
|
||||
}
|
||||
|
||||
if (props.status === "registration_required") {
|
||||
if (props.registrationStatus === "initial") {
|
||||
return emit("register")
|
||||
}
|
||||
|
||||
if (props.registrationStatus === "otp_required") {
|
||||
return emit("verify", userEnteredOTP.value)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hideModal = () => emit("hide-modal")
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<InterceptorsAgentRegistrationModal
|
||||
:show="showModal"
|
||||
:status="modalStatus"
|
||||
:registration-status="registrationStatus"
|
||||
@hide-modal="hideModal"
|
||||
@register="register"
|
||||
@verify="verifyOTP"
|
||||
@retry-connection="checkAgentStatus(true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useService } from "dioc/vue"
|
||||
import { AgentInterceptorService } from "~/platform/std/interceptors/agent"
|
||||
import { ref, onMounted, computed, watch } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
// TODO: Move as much as logic as possible to AgentInterceptorService
|
||||
|
||||
const interceptorService = useService(InterceptorService) // TODO: Try to remove dependency to InterceptorService
|
||||
const agentService = useService(AgentInterceptorService)
|
||||
const showModal = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const modalStatus = computed(() => {
|
||||
if (!agentService.isAgentRunning.value) return "agent_not_running"
|
||||
if (!agentService.isAuthKeyPresent()) return "registration_required"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
const registrationStatus = ref<"initial" | "otp_required" | "loading">(
|
||||
"initial"
|
||||
)
|
||||
|
||||
async function checkAgentStatus(isRetry = false) {
|
||||
if (
|
||||
interceptorService.currentInterceptor.value?.interceptorID ===
|
||||
agentService.interceptorID
|
||||
) {
|
||||
await agentService.checkAgentStatus()
|
||||
updateModalVisibility()
|
||||
|
||||
if (isRetry && !agentService.isAgentRunning.value) {
|
||||
toast.error("Agent is not running.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(interceptorService.currentInterceptor, () => {
|
||||
checkAgentStatus()
|
||||
})
|
||||
|
||||
function updateModalVisibility() {
|
||||
showModal.value = modalStatus.value !== "hidden"
|
||||
if (showModal.value && modalStatus.value === "registration_required") {
|
||||
registrationStatus.value = "initial"
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await checkAgentStatus()
|
||||
})
|
||||
|
||||
function hideModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function register() {
|
||||
registrationStatus.value = "loading"
|
||||
try {
|
||||
await agentService.initiateRegistration()
|
||||
registrationStatus.value = "otp_required"
|
||||
} catch (error) {
|
||||
toast.error("Failed to initiate registration. Please try again.")
|
||||
registrationStatus.value = "initial"
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyOTP(otp: string) {
|
||||
registrationStatus.value = "loading"
|
||||
try {
|
||||
await agentService.verifyRegistration(otp)
|
||||
toast.success("Registration successful!")
|
||||
hideModal()
|
||||
} catch (error) {
|
||||
toast.error("Failed to verify OTP. Please try again.")
|
||||
registrationStatus.value = "otp_required"
|
||||
}
|
||||
}
|
||||
|
||||
defineActionHandler("agent.open-registration-modal", () => {
|
||||
if (!showModal.value) {
|
||||
showModal.value = true
|
||||
registrationStatus.value = "initial"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
114
packages/hoppscotch-common/src/components/settings/Agent.vue
Normal file
114
packages/hoppscotch-common/src/components/settings/Agent.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle
|
||||
:on="allowSSLVerification"
|
||||
@change="allowSSLVerification = !allowSSLVerification"
|
||||
/>
|
||||
{{ t("agent.verify_ssl_certs") }}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<!--
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLucideFileBadge"
|
||||
:label="'CA Certificates'"
|
||||
outline
|
||||
@click="showCACertificatesModal = true"
|
||||
/>
|
||||
-->
|
||||
<!--
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLucideFileKey"
|
||||
:label="t('agent.client_certs')"
|
||||
outline
|
||||
@click="showClientCertificatesModal = true"
|
||||
/>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<ModalsNativeCACertificates
|
||||
:show="showCACertificatesModal"
|
||||
@hide-modal="showCACertificatesModal = false"
|
||||
/>
|
||||
-->
|
||||
|
||||
<!--
|
||||
<InterceptorsAgentModalNativeClientCertificates
|
||||
:show="showClientCertificatesModal"
|
||||
@hide-modal="showClientCertificatesModal = false"
|
||||
/>
|
||||
-->
|
||||
|
||||
<div class="pt-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" />
|
||||
{{ t("agent.use_http_proxy") }}
|
||||
</div>
|
||||
|
||||
<HoppSmartInput
|
||||
v-if="allowProxy"
|
||||
v-model="proxyURL"
|
||||
:autofocus="false"
|
||||
styles="flex-1"
|
||||
placeholder=" "
|
||||
:label="t('settings.proxy_url')"
|
||||
input-styles="input floating-input"
|
||||
/>
|
||||
|
||||
<p class="my-1 text-secondaryLight">
|
||||
{{ t("agent.proxy_capabilities") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
// import IconLucideFileKey from "~icons/lucide/file-key"
|
||||
import { useService } from "dioc/vue"
|
||||
import {
|
||||
RequestDef,
|
||||
AgentInterceptorService,
|
||||
} from "~/platform/std/interceptors/agent"
|
||||
import { syncRef } from "@vueuse/core"
|
||||
|
||||
type RequestProxyInfo = RequestDef["proxy"]
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const agentInterceptorService = useService(AgentInterceptorService)
|
||||
|
||||
const allowSSLVerification = agentInterceptorService.validateCerts
|
||||
|
||||
// const showCACertificatesModal = ref(false)
|
||||
// const showClientCertificatesModal = ref(false)
|
||||
|
||||
const allowProxy = ref(false)
|
||||
const proxyURL = ref("")
|
||||
|
||||
const proxyInfo = computed<RequestProxyInfo>({
|
||||
get() {
|
||||
if (allowProxy.value) {
|
||||
return {
|
||||
url: proxyURL.value,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
set(newData) {
|
||||
if (newData) {
|
||||
allowProxy.value = true
|
||||
proxyURL.value = newData.url
|
||||
} else {
|
||||
allowProxy.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
syncRef(agentInterceptorService.proxyInfo, proxyInfo, { direction: "both" })
|
||||
</script>
|
||||
@@ -78,6 +78,7 @@ export type HoppAction =
|
||||
| "share.request" // Share REST request
|
||||
| "tab.duplicate-tab" // Duplicate REST request
|
||||
| "gql.request.open" // Open GraphQL request
|
||||
| "agent.open-registration-modal" // Open Hoppscotch Agent registration modal
|
||||
|
||||
/**
|
||||
* Defines the arguments, if present for a given type that is required to be passed on
|
||||
|
||||
@@ -38,7 +38,10 @@ export function getSuitableLenses(response: HoppRESTResponse): Lens[] {
|
||||
)
|
||||
return []
|
||||
|
||||
const contentType = response.headers.find((h) => h.key === "content-type")
|
||||
// Lowercase the content-type key because HTTP Headers are case-insensitive by spec
|
||||
const contentType = response.headers.find(
|
||||
(h) => h.key.toLowerCase() === "content-type"
|
||||
)
|
||||
|
||||
if (!contentType) return [rawLens]
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@
|
||||
@hide-modal="showSupport = false"
|
||||
/>
|
||||
<AppOptions v-else :show="showSupport" @hide-modal="showSupport = false" />
|
||||
|
||||
<!-- Let additional stuff be registered -->
|
||||
<template
|
||||
v-for="(component, index) in rootExtensionComponents"
|
||||
:key="index"
|
||||
>
|
||||
<component :is="component" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,6 +86,7 @@ import { platform } from "~/platform"
|
||||
import { HoppSpotlightSessionEventData } from "~/platform/analytics"
|
||||
import { PersistenceService } from "~/services/persistence"
|
||||
import { SpotlightService } from "~/services/spotlight"
|
||||
import { UIExtensionService } from "~/services/ui-extension.service"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -96,6 +105,9 @@ const t = useI18n()
|
||||
|
||||
const persistenceService = useService(PersistenceService)
|
||||
const spotlightService = useService(SpotlightService)
|
||||
const uiExtensionService = useService(UIExtensionService)
|
||||
|
||||
const rootExtensionComponents = uiExtensionService.rootUIExtensionComponents
|
||||
|
||||
const HAS_OPENED_SPOTLIGHT = useSetting("HAS_OPENED_SPOTLIGHT")
|
||||
|
||||
|
||||
@@ -42,10 +42,16 @@ export class ExtensionInspectorService extends Service implements Inspector {
|
||||
() => currentExtensionStatus.value === "available"
|
||||
)
|
||||
|
||||
const EXTENSIONS_ENABLED = computed(
|
||||
() => this.interceptorService.currentInterceptorID.value === "extension"
|
||||
const activeInterceptor = computed(
|
||||
() => this.interceptorService.currentInterceptorID.value
|
||||
)
|
||||
|
||||
const EXTENSION_ENABLED = computed(
|
||||
() => activeInterceptor.value === "extension"
|
||||
)
|
||||
|
||||
const AGENT_ENABLED = computed(() => activeInterceptor.value === "agent")
|
||||
|
||||
return computed(() => {
|
||||
const results: InspectorResult[] = []
|
||||
|
||||
@@ -56,9 +62,11 @@ export class ExtensionInspectorService extends Service implements Inspector {
|
||||
url.includes(host)
|
||||
)
|
||||
|
||||
// Prompt the user to install or enable the extension via inspector if the endpoint is `localhost`, and an interceptor other than `Agent` is active
|
||||
if (
|
||||
isContainLocalhost &&
|
||||
(!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value)
|
||||
!AGENT_ENABLED.value &&
|
||||
(!EXTENSION_ENABLED.value || !isExtensionInstalled.value)
|
||||
) {
|
||||
let text
|
||||
|
||||
@@ -68,7 +76,7 @@ export class ExtensionInspectorService extends Service implements Inspector {
|
||||
} else {
|
||||
text = this.t("inspections.url.extension_not_installed")
|
||||
}
|
||||
} else if (!EXTENSIONS_ENABLED.value) {
|
||||
} else if (!EXTENSION_ENABLED.value) {
|
||||
text = this.t("inspections.url.extention_not_enabled")
|
||||
} else {
|
||||
text = this.t("inspections.url.localhost")
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
import { CookieJarService } from "~/services/cookie-jar.service"
|
||||
import {
|
||||
Interceptor,
|
||||
InterceptorError,
|
||||
InterceptorService,
|
||||
RequestRunResult,
|
||||
} from "~/services/interceptor.service"
|
||||
import { Service } from "dioc"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { ref, watch } from "vue"
|
||||
import { z } from "zod"
|
||||
import { PersistenceService } from "~/services/persistence"
|
||||
import {
|
||||
CACertStore,
|
||||
ClientCertsStore,
|
||||
ClientCertStore,
|
||||
StoredClientCert,
|
||||
} from "./persisted-data"
|
||||
import axios, { CancelTokenSource } from "axios"
|
||||
import SettingsAgentInterceptor from "~/components/settings/Agent.vue"
|
||||
import AgentRootUIExtension from "~/components/interceptors/agent/RootExt.vue"
|
||||
import { UIExtensionService } from "~/services/ui-extension.service"
|
||||
import { x25519 } from "@noble/curves/ed25519"
|
||||
import { base16 } from "@scure/base"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
|
||||
type KeyValuePair = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormDataValue =
|
||||
| { Text: string }
|
||||
| {
|
||||
File: {
|
||||
filename: string
|
||||
data: number[]
|
||||
mime: string
|
||||
}
|
||||
}
|
||||
|
||||
type FormDataEntry = {
|
||||
key: string
|
||||
value: FormDataValue
|
||||
}
|
||||
|
||||
type BodyDef =
|
||||
| { Text: string }
|
||||
| { URLEncoded: KeyValuePair[] }
|
||||
| { FormData: FormDataEntry[] }
|
||||
|
||||
type ClientCertDef =
|
||||
| {
|
||||
PEMCert: {
|
||||
certificate_pem: number[]
|
||||
key_pem: number[]
|
||||
}
|
||||
}
|
||||
| {
|
||||
PFXCert: {
|
||||
certificate_pfx: number[]
|
||||
password: string
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Figure out a way to autogen this from the interceptor definition on the Rust side
|
||||
export type RequestDef = {
|
||||
req_id: number
|
||||
|
||||
method: string
|
||||
endpoint: string
|
||||
|
||||
headers: KeyValuePair[]
|
||||
|
||||
body: BodyDef | null
|
||||
|
||||
validate_certs: boolean
|
||||
root_cert_bundle_files: number[][]
|
||||
client_cert: ClientCertDef | null
|
||||
|
||||
proxy?: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
type RunRequestResponse = {
|
||||
status: number
|
||||
status_text: string
|
||||
headers: KeyValuePair[]
|
||||
data: number[]
|
||||
|
||||
time_start_ms: number
|
||||
time_end_ms: number
|
||||
}
|
||||
|
||||
// HACK: To solve the AxiosRequestConfig being different between @hoppscotch/common
|
||||
// and the axios present in this package
|
||||
type AxiosRequestConfig = Parameters<Interceptor["runRequest"]>[0]
|
||||
|
||||
export const preProcessRequest = (
|
||||
req: AxiosRequestConfig
|
||||
): AxiosRequestConfig => {
|
||||
const reqClone = cloneDeep(req)
|
||||
|
||||
// If the parameters are URLSearchParams, inject them to URL instead
|
||||
// This prevents issues of marshalling the URLSearchParams to the proxy
|
||||
if (reqClone.params instanceof URLSearchParams) {
|
||||
try {
|
||||
const url = new URL(reqClone.url ?? "")
|
||||
|
||||
for (const [key, value] of reqClone.params.entries()) {
|
||||
url.searchParams.append(key, value)
|
||||
}
|
||||
|
||||
reqClone.url = url.toString()
|
||||
} catch (e) {
|
||||
// making this a non-empty block, so we can make the linter happy.
|
||||
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
|
||||
}
|
||||
|
||||
reqClone.params = {}
|
||||
}
|
||||
|
||||
return reqClone
|
||||
}
|
||||
|
||||
async function processBody(
|
||||
axiosReq: AxiosRequestConfig
|
||||
): Promise<BodyDef | null> {
|
||||
if (!axiosReq.data) return null
|
||||
|
||||
if (typeof axiosReq.data === "string") {
|
||||
return { Text: axiosReq.data }
|
||||
}
|
||||
|
||||
if (axiosReq.data instanceof FormData) {
|
||||
const entries: FormDataEntry[] = []
|
||||
|
||||
for (const [key, value] of axiosReq.data.entries()) {
|
||||
if (typeof value === "string") {
|
||||
entries.push({
|
||||
key,
|
||||
value: { Text: value },
|
||||
})
|
||||
} else {
|
||||
const mime = value.type !== "" ? value.type : "application/octet-stream"
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
value: {
|
||||
File: {
|
||||
filename: value.name,
|
||||
data: Array.from(new Uint8Array(await value.arrayBuffer())),
|
||||
mime,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { FormData: entries }
|
||||
}
|
||||
|
||||
throw new Error("Agent Process Body: Unhandled Axios Request Configuration")
|
||||
}
|
||||
|
||||
function getURLDomain(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function convertClientCertToDefCert(
|
||||
cert: ClientCertificateEntry
|
||||
): ClientCertDef {
|
||||
if ("PEMCert" in cert.cert) {
|
||||
return {
|
||||
PEMCert: {
|
||||
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
|
||||
key_pem: Array.from(cert.cert.PEMCert.key_pem),
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
PFXCert: {
|
||||
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
|
||||
password: cert.cert.PFXCert.password,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function convertToRequestDef(
|
||||
axiosReq: AxiosRequestConfig,
|
||||
reqID: number,
|
||||
caCertificates: CACertificateEntry[],
|
||||
clientCertificates: Map<string, ClientCertificateEntry>,
|
||||
validateCerts: boolean,
|
||||
proxyInfo: RequestDef["proxy"]
|
||||
): Promise<RequestDef> {
|
||||
const clientCertDomain = getURLDomain(axiosReq.url!)
|
||||
|
||||
const clientCert = clientCertDomain
|
||||
? clientCertificates.get(clientCertDomain)
|
||||
: null
|
||||
|
||||
const urlObj = new URL(axiosReq.url ?? "")
|
||||
|
||||
// If there are parameters in axiosReq.params, add them to the URL.
|
||||
if (axiosReq.params) {
|
||||
const params = new URLSearchParams(urlObj.search) // Taking in existing params if are any.
|
||||
|
||||
Object.entries(axiosReq.params as Record<string, string>).forEach(
|
||||
([key, value]) => {
|
||||
params.append(key, value)
|
||||
}
|
||||
)
|
||||
|
||||
urlObj.search = params.toString() // Now put back all the params in the URL.
|
||||
}
|
||||
|
||||
return {
|
||||
req_id: reqID,
|
||||
method: axiosReq.method ?? "GET",
|
||||
endpoint: urlObj.toString(), // This is the updated URL with parms.
|
||||
headers: Object.entries(axiosReq.headers ?? {})
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!(
|
||||
key.toLowerCase() === "content-type" &&
|
||||
value.toLowerCase() === "multipart/form-data"
|
||||
)
|
||||
) // Removing header, because this header will be set by agent.
|
||||
.map(([key, value]): KeyValuePair => ({ key, value })),
|
||||
|
||||
// NOTE: Injected parameters are already part of the URL
|
||||
|
||||
body: await processBody(axiosReq),
|
||||
root_cert_bundle_files: caCertificates.map((cert) =>
|
||||
Array.from(cert.certificate)
|
||||
),
|
||||
validate_certs: validateCerts,
|
||||
client_cert: clientCert ? convertClientCertToDefCert(clientCert) : null,
|
||||
proxy: proxyInfo,
|
||||
}
|
||||
}
|
||||
|
||||
export const CACertificateEntry = z.object({
|
||||
filename: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
certificate: z.instanceof(Uint8Array),
|
||||
})
|
||||
|
||||
export type CACertificateEntry = z.infer<typeof CACertificateEntry>
|
||||
|
||||
export const ClientCertificateEntry = z.object({
|
||||
enabled: z.boolean(),
|
||||
domain: z.string().trim().min(1),
|
||||
cert: z.union([
|
||||
z.object({
|
||||
PEMCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pem: z.instanceof(Uint8Array),
|
||||
|
||||
key_filename: z.string().min(1),
|
||||
key_pem: z.instanceof(Uint8Array),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
PFXCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pfx: z.instanceof(Uint8Array),
|
||||
|
||||
password: z.string(),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
export type ClientCertificateEntry = z.infer<typeof ClientCertificateEntry>
|
||||
|
||||
const CA_STORE_PERSIST_KEY = "agent_interceptor_ca_store"
|
||||
const CLIENT_CERTS_PERSIST_KEY = "agent_interceptor_client_certs_store"
|
||||
const VALIDATE_SSL_KEY = "agent_interceptor_validate_ssl"
|
||||
const AUTH_KEY_PERSIST_KEY = "agent_interceptor_auth_key"
|
||||
const SHARED_SECRET_PERSIST_KEY = "agent_interceptor_shared_secret"
|
||||
const PROXY_INFO_PERSIST_KEY = "agent_interceptor_proxy_info"
|
||||
|
||||
export class AgentInterceptorService extends Service implements Interceptor {
|
||||
public static readonly ID = "AGENT_INTERCEPTOR_SERVICE"
|
||||
|
||||
public interceptorID = "agent"
|
||||
|
||||
// TODO: Better User facing name
|
||||
public name = () => "Agent"
|
||||
|
||||
public selectable = { type: "selectable" as const }
|
||||
|
||||
public supportsCookies = true
|
||||
|
||||
private interceptorService = this.bind(InterceptorService)
|
||||
private cookieJarService = this.bind(CookieJarService)
|
||||
private persistenceService = this.bind(PersistenceService)
|
||||
private uiExtensionService = this.bind(UIExtensionService)
|
||||
|
||||
public isAgentRunning = ref(false)
|
||||
|
||||
private reqIDTicker = 0
|
||||
private cancelTokens: Map<number, CancelTokenSource> = new Map()
|
||||
|
||||
public settingsPageEntry = {
|
||||
entryTitle: () => "Agent", // TODO: i18n this
|
||||
component: SettingsAgentInterceptor,
|
||||
}
|
||||
|
||||
public caCertificates = ref<CACertificateEntry[]>([])
|
||||
|
||||
public clientCertificates = ref<Map<string, ClientCertificateEntry>>(
|
||||
new Map()
|
||||
)
|
||||
public validateCerts = ref(true)
|
||||
|
||||
public showRegistrationModal = ref(false)
|
||||
public authKey = ref<string | null>(null)
|
||||
public sharedSecretB16 = ref<string | null>(null)
|
||||
private registrationOTP = ref<string | null>(null)
|
||||
|
||||
public proxyInfo = ref<RequestDef["proxy"]>(undefined)
|
||||
|
||||
override onServiceInit() {
|
||||
// Register the Root UI Extension
|
||||
this.uiExtensionService.addRootUIExtension(AgentRootUIExtension)
|
||||
|
||||
const persistedAuthKey =
|
||||
this.persistenceService.getLocalConfig(AUTH_KEY_PERSIST_KEY)
|
||||
if (persistedAuthKey) {
|
||||
this.authKey.value = persistedAuthKey
|
||||
}
|
||||
|
||||
const sharedSecret = this.persistenceService.getLocalConfig(
|
||||
SHARED_SECRET_PERSIST_KEY
|
||||
)
|
||||
if (sharedSecret) {
|
||||
this.sharedSecretB16.value = sharedSecret
|
||||
}
|
||||
|
||||
const persistedProxyInfo = this.persistenceService.getLocalConfig(
|
||||
PROXY_INFO_PERSIST_KEY
|
||||
)
|
||||
if (persistedProxyInfo && persistedProxyInfo !== "null") {
|
||||
try {
|
||||
const proxyInfo = JSON.parse(persistedProxyInfo)
|
||||
this.proxyInfo.value = proxyInfo
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Load SSL Validation
|
||||
const persistedValidateSSL: unknown = JSON.parse(
|
||||
this.persistenceService.getLocalConfig(VALIDATE_SSL_KEY) ?? "null"
|
||||
)
|
||||
|
||||
if (typeof persistedValidateSSL === "boolean") {
|
||||
this.validateCerts.value = persistedValidateSSL
|
||||
}
|
||||
|
||||
watch(this.validateCerts, () => {
|
||||
this.persistenceService.setLocalConfig(
|
||||
VALIDATE_SSL_KEY,
|
||||
JSON.stringify(this.validateCerts.value)
|
||||
)
|
||||
})
|
||||
|
||||
// Load and setup writes for CA Store
|
||||
const persistedCAStoreData = JSON.parse(
|
||||
this.persistenceService.getLocalConfig(CA_STORE_PERSIST_KEY) ?? "null"
|
||||
)
|
||||
|
||||
const caStoreDataParseResult = CACertStore.safeParse(persistedCAStoreData)
|
||||
|
||||
if (caStoreDataParseResult.type === "ok") {
|
||||
this.caCertificates.value = caStoreDataParseResult.value.certs.map(
|
||||
(entry) => ({
|
||||
...entry,
|
||||
certificate: new Uint8Array(entry.certificate),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
watch(this.caCertificates, (certs) => {
|
||||
const storableValue: CACertStore = {
|
||||
v: 1,
|
||||
certs: certs.map((el) => ({
|
||||
...el,
|
||||
certificate: Array.from(el.certificate),
|
||||
})),
|
||||
}
|
||||
|
||||
this.persistenceService.setLocalConfig(
|
||||
CA_STORE_PERSIST_KEY,
|
||||
JSON.stringify(storableValue)
|
||||
)
|
||||
})
|
||||
|
||||
// Load and setup writes for Client Certs Store
|
||||
const persistedClientCertStoreData = JSON.parse(
|
||||
this.persistenceService.getLocalConfig(CLIENT_CERTS_PERSIST_KEY) ?? "null"
|
||||
)
|
||||
|
||||
const clientCertStoreDataParseResult = ClientCertsStore.safeParse(
|
||||
persistedClientCertStoreData
|
||||
)
|
||||
|
||||
if (clientCertStoreDataParseResult.type === "ok") {
|
||||
this.clientCertificates.value = new Map(
|
||||
Object.entries(clientCertStoreDataParseResult.value.clientCerts).map(
|
||||
([domain, cert]) => {
|
||||
if ("PFXCert" in cert.cert) {
|
||||
const newCert = <ClientCertificateEntry>{
|
||||
...cert,
|
||||
cert: {
|
||||
PFXCert: {
|
||||
certificate_pfx: new Uint8Array(
|
||||
cert.cert.PFXCert.certificate_pfx
|
||||
),
|
||||
certificate_filename:
|
||||
cert.cert.PFXCert.certificate_filename,
|
||||
|
||||
password: cert.cert.PFXCert.password,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
}
|
||||
const newCert = <ClientCertificateEntry>{
|
||||
...cert,
|
||||
cert: {
|
||||
PEMCert: {
|
||||
certificate_pem: new Uint8Array(
|
||||
cert.cert.PEMCert.certificate_pem
|
||||
),
|
||||
certificate_filename: cert.cert.PEMCert.certificate_filename,
|
||||
|
||||
key_pem: new Uint8Array(cert.cert.PEMCert.key_pem),
|
||||
key_filename: cert.cert.PEMCert.key_filename,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
watch(this.clientCertificates, (certs) => {
|
||||
const storableValue: ClientCertStore = {
|
||||
v: 1,
|
||||
clientCerts: Object.fromEntries(
|
||||
Array.from(certs.entries()).map(([domain, cert]) => {
|
||||
if ("PFXCert" in cert.cert) {
|
||||
const newCert = <StoredClientCert>{
|
||||
...cert,
|
||||
cert: {
|
||||
PFXCert: {
|
||||
certificate_pfx: Array.from(
|
||||
cert.cert.PFXCert.certificate_pfx
|
||||
),
|
||||
certificate_filename:
|
||||
cert.cert.PFXCert.certificate_filename,
|
||||
|
||||
password: cert.cert.PFXCert.password,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
}
|
||||
const newCert = <StoredClientCert>{
|
||||
...cert,
|
||||
cert: {
|
||||
PEMCert: {
|
||||
certificate_pem: Array.from(
|
||||
cert.cert.PEMCert.certificate_pem
|
||||
),
|
||||
certificate_filename: cert.cert.PEMCert.certificate_filename,
|
||||
|
||||
key_pem: Array.from(cert.cert.PEMCert.key_pem),
|
||||
key_filename: cert.cert.PEMCert.key_filename,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
this.persistenceService.setLocalConfig(
|
||||
CLIENT_CERTS_PERSIST_KEY,
|
||||
JSON.stringify(storableValue)
|
||||
)
|
||||
})
|
||||
|
||||
watch(this.authKey, (newAuthKey) => {
|
||||
if (newAuthKey) {
|
||||
this.persistenceService.setLocalConfig(AUTH_KEY_PERSIST_KEY, newAuthKey)
|
||||
} else {
|
||||
this.persistenceService.removeLocalConfig(AUTH_KEY_PERSIST_KEY)
|
||||
}
|
||||
})
|
||||
|
||||
watch(this.proxyInfo, (newProxyInfo) => {
|
||||
this.persistenceService.setLocalConfig(
|
||||
PROXY_INFO_PERSIST_KEY,
|
||||
JSON.stringify(newProxyInfo) ?? "null"
|
||||
)
|
||||
})
|
||||
|
||||
// Show registration UI if there is no auth key present
|
||||
watch(
|
||||
[this.interceptorService.currentInterceptor, this.authKey],
|
||||
([currentInterceptor, authKey]) => {
|
||||
if (
|
||||
currentInterceptor?.interceptorID === this.interceptorID &&
|
||||
authKey === null
|
||||
) {
|
||||
this.showRegistrationModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
|
||||
// Verify if the agent registration still holds, else revoke the registration
|
||||
if (this.authKey.value) {
|
||||
;(async () => {
|
||||
try {
|
||||
const nonce = window.crypto.getRandomValues(new Uint8Array(12))
|
||||
const nonceB16 = base16.encode(nonce).toLowerCase()
|
||||
|
||||
const response = await axios.get(
|
||||
"http://localhost:9119/registered-handshake",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.authKey.value}`,
|
||||
"X-Hopp-Nonce": nonceB16,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
|
||||
const responseNonceB16: string = response.headers["x-hopp-nonce"]
|
||||
const encryptedResponseBytes = response.data
|
||||
|
||||
const parsedData = await this.getDecryptedResponse<unknown>(
|
||||
responseNonceB16,
|
||||
encryptedResponseBytes
|
||||
)
|
||||
|
||||
// This should decrypt directly into `true` else registration failed
|
||||
if (parsedData !== true) {
|
||||
throw "handshake-mismatch"
|
||||
}
|
||||
} catch (e) {
|
||||
if (e === "handshake-mismatch") {
|
||||
this.sharedSecretB16.value = null
|
||||
this.authKey.value = null
|
||||
} else if (axios.isAxiosError(e) && e.status === 401) {
|
||||
this.sharedSecretB16.value = null
|
||||
this.authKey.value = null
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
public async checkAgentStatus(): Promise<void> {
|
||||
try {
|
||||
await this.performHandshake()
|
||||
this.isAgentRunning.value = true
|
||||
} catch (error) {
|
||||
this.isAgentRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
public isAuthKeyPresent(): boolean {
|
||||
return this.authKey.value !== null
|
||||
}
|
||||
|
||||
private generateOTP(): string {
|
||||
// This generates a 6-digit numeric OTP
|
||||
return Math.floor(100000 + Math.random() * 900000).toString()
|
||||
}
|
||||
|
||||
public async performHandshake(): Promise<void> {
|
||||
const handshakeResponse = await axios.get("http://localhost:9119/handshake")
|
||||
if (
|
||||
handshakeResponse.data.status !== "success" &&
|
||||
handshakeResponse.data.__hoppscotch__agent__ === true
|
||||
) {
|
||||
throw new Error("Handshake failed")
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateRegistration() {
|
||||
try {
|
||||
// Generate OTP and send registration request
|
||||
this.registrationOTP.value = this.generateOTP()
|
||||
|
||||
const registrationResponse = await axios.post(
|
||||
"http://localhost:9119/receive-registration",
|
||||
{
|
||||
registration: this.registrationOTP.value,
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
registrationResponse.data.message !== "Registration received and stored"
|
||||
) {
|
||||
throw new Error("Registration failed")
|
||||
}
|
||||
|
||||
// Registration successful, modal will handle showing the OTP input
|
||||
} catch (error) {
|
||||
console.error("Registration initiation failed:", error)
|
||||
throw error // Re-throw to let the modal handle the error
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyRegistration(userEnteredOTP: string) {
|
||||
try {
|
||||
const myPrivateKey = x25519.utils.randomPrivateKey()
|
||||
const myPublicKey = x25519.getPublicKey(myPrivateKey)
|
||||
|
||||
const myPublicKeyB16 = base16.encode(myPublicKey).toLowerCase()
|
||||
|
||||
const verificationResponse = await axios.post(
|
||||
"http://localhost:9119/verify-registration",
|
||||
{
|
||||
registration: userEnteredOTP,
|
||||
client_public_key_b16: myPublicKeyB16,
|
||||
}
|
||||
)
|
||||
|
||||
const newAuthKey = verificationResponse.data.auth_key
|
||||
const agentPublicKeyB16: string =
|
||||
verificationResponse.data.agent_public_key_b16
|
||||
|
||||
const agentPublicKey = base16.decode(agentPublicKeyB16.toUpperCase())
|
||||
|
||||
const sharedSecret = x25519.getSharedSecret(myPrivateKey, agentPublicKey)
|
||||
const sharedSecretB16 = base16.encode(sharedSecret).toLowerCase()
|
||||
|
||||
if (typeof newAuthKey === "string") {
|
||||
this.authKey.value = newAuthKey
|
||||
this.sharedSecretB16.value = sharedSecretB16
|
||||
this.persistenceService.setLocalConfig(AUTH_KEY_PERSIST_KEY, newAuthKey)
|
||||
this.persistenceService.setLocalConfig(
|
||||
SHARED_SECRET_PERSIST_KEY,
|
||||
sharedSecretB16
|
||||
)
|
||||
} else {
|
||||
throw new Error("Invalid auth key received")
|
||||
}
|
||||
|
||||
this.showRegistrationModal.value = false
|
||||
this.registrationOTP.value = null
|
||||
} catch (error) {
|
||||
console.error("Verification failed:", error)
|
||||
throw new Error("Verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
private async getEncryptedRequestDef(
|
||||
def: RequestDef
|
||||
): Promise<[string, ArrayBuffer]> {
|
||||
const defJSON = JSON.stringify(def)
|
||||
const defJSONBytes = new TextEncoder().encode(defJSON)
|
||||
|
||||
const nonce = window.crypto.getRandomValues(new Uint8Array(12))
|
||||
const nonceB16 = base16.encode(nonce).toLowerCase()
|
||||
|
||||
const sharedSecretKeyBytes = base16.decode(
|
||||
this.sharedSecretB16.value!.toUpperCase()
|
||||
)
|
||||
|
||||
const sharedSecretKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecretKeyBytes,
|
||||
"AES-GCM",
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
const encryptedDef = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
sharedSecretKey,
|
||||
defJSONBytes
|
||||
)
|
||||
|
||||
return [nonceB16, encryptedDef]
|
||||
}
|
||||
|
||||
private async getDecryptedResponse<T>(
|
||||
nonceB16: string,
|
||||
responseData: ArrayBuffer
|
||||
) {
|
||||
const sharedSecretKeyBytes = base16.decode(
|
||||
this.sharedSecretB16.value!.toUpperCase()
|
||||
)
|
||||
|
||||
const sharedSecretKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecretKeyBytes,
|
||||
"AES-GCM",
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
const nonce = base16.decode(nonceB16.toUpperCase())
|
||||
|
||||
const plainTextDefBytes = await window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
sharedSecretKey,
|
||||
responseData
|
||||
)
|
||||
|
||||
const plainText = new TextDecoder().decode(plainTextDefBytes)
|
||||
|
||||
return JSON.parse(plainText) as T
|
||||
}
|
||||
|
||||
public runRequest(
|
||||
req: AxiosRequestConfig
|
||||
): RequestRunResult<InterceptorError> {
|
||||
// TODO: Check if auth key is defined ?
|
||||
|
||||
const processedReq = preProcessRequest(req)
|
||||
|
||||
const relevantCookies = this.cookieJarService.getCookiesForURL(
|
||||
new URL(processedReq.url!)
|
||||
)
|
||||
|
||||
if (relevantCookies.length > 0) {
|
||||
processedReq.headers!["Cookie"] = relevantCookies
|
||||
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
|
||||
.join(";")
|
||||
}
|
||||
|
||||
const reqID = this.reqIDTicker++
|
||||
|
||||
const cancelTokenSource = axios.CancelToken.source()
|
||||
this.cancelTokens.set(reqID, cancelTokenSource)
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
const cancelTokenSource = this.cancelTokens.get(reqID)
|
||||
if (cancelTokenSource) {
|
||||
cancelTokenSource.cancel("Request cancelled")
|
||||
this.cancelTokens.delete(reqID)
|
||||
axios
|
||||
.post(
|
||||
`http://localhost:9119/cancel-request/${reqID}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.authKey.value}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch((error) => console.error("Error cancelling request:", error))
|
||||
}
|
||||
},
|
||||
response: (async () => {
|
||||
await this.checkAgentStatus()
|
||||
|
||||
if (!this.isAgentRunning.value || !this.authKey.value) {
|
||||
invokeAction("agent.open-registration-modal")
|
||||
|
||||
return E.left(<InterceptorError>{
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => t("helpers.network_fail"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const requestDef = await convertToRequestDef(
|
||||
processedReq,
|
||||
reqID,
|
||||
this.caCertificates.value,
|
||||
this.clientCertificates.value,
|
||||
this.validateCerts.value,
|
||||
this.proxyInfo.value
|
||||
)
|
||||
|
||||
const [nonceB16, encryptedDef] =
|
||||
await this.getEncryptedRequestDef(requestDef)
|
||||
|
||||
try {
|
||||
const http_response = await axios.post(
|
||||
"http://localhost:9119/request",
|
||||
encryptedDef,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.authKey.value}`,
|
||||
"X-Hopp-Nonce": nonceB16,
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
cancelToken: cancelTokenSource.token,
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
|
||||
const responseNonceB16: string = http_response.headers["x-hopp-nonce"]
|
||||
const encryptedResponseBytes = http_response.data
|
||||
|
||||
const response = await this.getDecryptedResponse<RunRequestResponse>(
|
||||
responseNonceB16,
|
||||
encryptedResponseBytes
|
||||
)
|
||||
|
||||
// TODO: Run it against a Zod Schema validation
|
||||
|
||||
return E.right({
|
||||
headers: Object.fromEntries(
|
||||
response.headers.map(({ key, value }) => [key, value])
|
||||
),
|
||||
status: response.status,
|
||||
statusText: response.status_text,
|
||||
data: new Uint8Array(response.data).buffer,
|
||||
config: {
|
||||
timeData: {
|
||||
startTime: response.time_start_ms,
|
||||
endTime: response.time_end_ms,
|
||||
},
|
||||
},
|
||||
additional: {
|
||||
multiHeaders: response.headers,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
if (typeof e === "object" && (e as any)["RequestCancelled"]) {
|
||||
return E.left("cancellation" as const)
|
||||
}
|
||||
|
||||
// TODO: More in-depth error messages
|
||||
return E.left(<InterceptorError>{
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => t("helpers.network_fail"),
|
||||
},
|
||||
})
|
||||
}
|
||||
})(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod"
|
||||
import { defineVersion, createVersionedEntity, InferredEntity } from "verzod"
|
||||
|
||||
const Uint8 = z.number().int().gte(0).lte(255)
|
||||
|
||||
export const StoredCACert = z.object({
|
||||
filename: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
certificate: z.array(Uint8),
|
||||
})
|
||||
|
||||
const caCertStore_v1 = defineVersion({
|
||||
initial: true,
|
||||
schema: z.object({
|
||||
v: z.literal(1),
|
||||
certs: z.array(StoredCACert),
|
||||
}),
|
||||
})
|
||||
|
||||
export const CACertStore = createVersionedEntity({
|
||||
latestVersion: 1,
|
||||
versionMap: {
|
||||
1: caCertStore_v1,
|
||||
},
|
||||
getVersion(data) {
|
||||
const result = caCertStore_v1.schema.safeParse(data)
|
||||
|
||||
return result.success ? result.data.v : null
|
||||
},
|
||||
})
|
||||
|
||||
export type CACertStore = InferredEntity<typeof CACertStore>
|
||||
|
||||
export const StoredClientCert = z.object({
|
||||
enabled: z.boolean(),
|
||||
domain: z.string().trim().min(1),
|
||||
cert: z.union([
|
||||
z.object({
|
||||
PEMCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pem: z.array(Uint8),
|
||||
|
||||
key_filename: z.string().min(1),
|
||||
key_pem: z.array(Uint8),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
PFXCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pfx: z.array(Uint8),
|
||||
|
||||
password: z.string(),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
export type StoredClientCert = z.infer<typeof StoredClientCert>
|
||||
|
||||
const clientCertsStore_v1 = defineVersion({
|
||||
initial: true,
|
||||
schema: z.object({
|
||||
v: z.literal(1),
|
||||
clientCerts: z.record(StoredClientCert),
|
||||
}),
|
||||
})
|
||||
|
||||
export const ClientCertsStore = createVersionedEntity({
|
||||
latestVersion: 1,
|
||||
versionMap: {
|
||||
1: clientCertsStore_v1,
|
||||
},
|
||||
getVersion(data) {
|
||||
const result = clientCertsStore_v1.schema.safeParse(data)
|
||||
|
||||
return result.success ? result.data.v : null
|
||||
},
|
||||
})
|
||||
|
||||
export type ClientCertStore = InferredEntity<typeof ClientCertsStore>
|
||||
@@ -5,7 +5,10 @@ import { Service } from "dioc"
|
||||
* supposed to be used only in development.
|
||||
*
|
||||
* This service logs events from the container and also events
|
||||
* from all the services that are bound to the container.
|
||||
* from all the services that are bound to the container. Along with that
|
||||
* this service exposes all registered services (including this) to global
|
||||
* scope (window) under the ID of the service so it can be accessed using
|
||||
* the console for debugging.
|
||||
*
|
||||
* This service injects couple of utilities into the global scope:
|
||||
* - `_getService(id: string): Service | undefined` - Returns the service instance with the given ID or undefined.
|
||||
@@ -15,27 +18,30 @@ export class DebugService extends Service {
|
||||
public static readonly ID = "DEBUG_SERVICE"
|
||||
|
||||
override onServiceInit() {
|
||||
console.log("DebugService is initialized...")
|
||||
console.debug("DebugService is initialized...")
|
||||
|
||||
const container = this.getContainer()
|
||||
|
||||
// Log container events
|
||||
container.getEventStream().subscribe((event) => {
|
||||
if (event.type === "SERVICE_BIND") {
|
||||
console.log(
|
||||
console.debug(
|
||||
"[CONTAINER] Service Bind:",
|
||||
event.bounderID ?? "<CONTAINER>",
|
||||
"->",
|
||||
event.boundeeID
|
||||
)
|
||||
} else if (event.type === "SERVICE_INIT") {
|
||||
console.log("[CONTAINER] Service Init:", event.serviceID)
|
||||
console.debug("[CONTAINER] Service Init:", event.serviceID)
|
||||
|
||||
// Subscribe to event stream of the newly initialized service
|
||||
const service = container.getBoundServiceWithID(event.serviceID)
|
||||
|
||||
// Expose the service globally for debugging via a global variable
|
||||
;(window as any)[event.serviceID] = service
|
||||
|
||||
service?.getEventStream().subscribe((ev: any) => {
|
||||
console.log(`[${event.serviceID}] Event:`, ev)
|
||||
console.debug(`[${event.serviceID}] Event:`, ev)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -43,8 +49,11 @@ export class DebugService extends Service {
|
||||
// Subscribe to event stream of all already bound services (if any)
|
||||
for (const [id, service] of container.getBoundServices()) {
|
||||
service.getEventStream().subscribe((event: any) => {
|
||||
console.log(`[${id}]`, event)
|
||||
console.debug(`[${id}]`, event)
|
||||
})
|
||||
|
||||
// Expose the service globally for debugging via a global variable
|
||||
;(window as any)[id] = service
|
||||
}
|
||||
|
||||
// Inject debug utilities into the global scope
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Service } from "dioc"
|
||||
import { Component, shallowRef } from "vue"
|
||||
|
||||
/**
|
||||
* A registrar that allows other services to register
|
||||
* additional stuff into the UI
|
||||
*/
|
||||
export class UIExtensionService extends Service {
|
||||
public static readonly ID = "UI_EXTENSION_SERVICE"
|
||||
|
||||
/**
|
||||
* Defines the Root UI Extensions that are registered.
|
||||
* These components are rendered in `layouts/default.vue`.
|
||||
* NOTE: This is supposed to be readonly, to register
|
||||
*/
|
||||
public rootUIExtensionComponents = shallowRef<Component[]>([])
|
||||
|
||||
/**
|
||||
* Registers a root UI extension component that will be rendered
|
||||
* in the root of the UI
|
||||
*/
|
||||
public addRootUIExtension(component: Component) {
|
||||
this.rootUIExtensionComponents.value.push(component)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user