diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
index ccd7bb7d7..a3252fcd3 100644
--- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
+++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
@@ -1,3 +1,5 @@
+import { useId, useState } from 'react'
+
import { CheckIcon } from 'lib/icons/Check.js'
import { TriangleWarningOutlineIcon } from 'lib/icons/TriangleWarningOutline.js'
import {
@@ -22,6 +24,8 @@ export function DeviceModelRow({
deviceModel,
manufacturer,
}: DeviceModelRowProps): JSX.Element {
+ const detailsId = useId()
+ const [expanded, setExpanded] = useState(false)
const name = getDeviceModelName(deviceModel)
const imageUrl = getDeviceModelImageUrl(deviceModel, manufacturer)
const modelIds = [
@@ -74,29 +78,51 @@ export function DeviceModelRow({
)}
{capabilities.length > 0 && (
-
- {capabilities.map((capability) => (
- -
- {capability.supported === 'conditionally' ? (
-
- ) : (
-
- )}
-
- {getCapabilityLabel(capability.name)}
- {capability.integrationSupportLevel === 'beta' && ' (beta)'}
- {capability.supported === 'conditionally' && (
-
- {` (${t.requirementsBelow})`}
-
+
+
+ {t.features}
+
+
+ {capabilities.map((capability) => (
+ -
+ {capability.supported === 'conditionally' ? (
+
+ ) : (
+
)}
-
-
- ))}
-
+
+ {getCapabilityLabel(capability.name)}
+ {capability.integrationSupportLevel === 'beta' && ' (beta)'}
+ {capability.supported === 'conditionally' && (
+
+ {` (${t.requirementsDetails})`}
+
+ )}
+
+
+ ))}
+
+
)}
{deviceModel.capabilities_stale ? (
{t.refreshing}
@@ -104,41 +130,52 @@ export function DeviceModelRow({
deviceModel.device_type?.is_accessory !== true ? (
{t.unconfirmed}
) : null}
- {requirements.size > 0 && (
- 0 ||
+ modelIds.length > 0 ||
+ providers.length > 0) && (
+ {
+ setExpanded(event.currentTarget.open)
+ }}
>
- {[...requirements].map(([message, features]) => (
- -
-
-
- {message}
-
- {t.requiredFor([...features].join(', '))}
-
-
-
- ))}
-
- )}
- {(modelIds.length > 0 || providers.length > 0) && (
-
{t.modelDetails}
-
- {modelIds.length > 0 && (
-
-
- {t.modelNumbers}
- - {modelIds.join(', ')}
-
- )}
- {providers.length > 0 && (
-
-
- {t.integration}
- - {providers.join(', ')}
-
- )}
-
+ {requirements.size > 0 && (
+
+ )}
+ {(modelIds.length > 0 || providers.length > 0) && (
+
+ {modelIds.length > 0 && (
+
+
- {t.modelNumbers}
+ - {modelIds.join(', ')}
+
+ )}
+ {providers.length > 0 && (
+
+
- {t.integration}
+ - {providers.join(', ')}
+
+ )}
+
+ )}
)}
@@ -146,11 +183,13 @@ export function DeviceModelRow({
}
const t = {
- features: 'Supported features',
+ features: 'Supported capabilities',
requirements: 'Requirements',
- requirementsBelow: 'see requirements below',
+ requirementsDetails: 'expand model and requirements details',
+ toggleRequirements: (capability: string, device: string) =>
+ `${capability} requirements for ${device}`,
requiredFor: (features: string) => `For ${features}`,
- modelDetails: 'Model details',
+ modelDetails: 'Model and requirements details',
modelNumbers: 'Model numbers',
integration: 'Integration',
refreshing: 'Compatibility is being refreshed.',
diff --git a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx
index 3d8075151..66f210f2d 100644
--- a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx
+++ b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx
@@ -13,7 +13,12 @@ export function RequirementIcon({ message }: { message: string }): JSX.Element {
strokeLinecap='round'
strokeLinejoin='round'
>
- {/keypad/i.test(message) ? (
+ {/subscription|licen[sc]e|paying|paid|fees?/i.test(message) ? (
+ <>
+
+
+ >
+ ) : /keypad/i.test(message) ? (
<>
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
index e57e5e3ca..cf1846b03 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen, within } from '@testing-library/react'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import {
@@ -106,13 +106,34 @@ test('SupportedDevices scopes the query and badges to a pr
expect(request?.searchParams.get('provider')).toBe('schlage-engage')
})
-test('conditional requirements are readable without hovering', async (ctx) => {
+test('warning buttons toggle requirements and model details together', async (ctx) => {
renderWithEcosystem(, ctx)
const name = await screen.findByText('Smart Lock Pro')
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
+ const requirement = within(row).getByText(/Requires an August keypad/)
+ const details = requirement.closest('details')
+ expect(details?.hasAttribute('open')).toBe(false)
+ expect(within(row).getByText('Model numbers').closest('details')).toBe(
+ details
+ )
+ const warnings = within(row).getAllByRole('button', {
+ name: /requirements for/,
+ })
+ const warning = warnings[0]
+ if (warning == null) throw new Error('Missing warning control')
+ expect(warning.getAttribute('aria-controls')).toBe(details?.id)
+ fireEvent.click(warning)
+ await waitFor(() => {
+ expect(details?.hasAttribute('open')).toBe(true)
+ })
+ for (const button of warnings)
+ expect(button.getAttribute('aria-expanded')).toBe('true')
within(row).getByRole('list', { name: 'Requirements' })
- within(row).getByText(/Requires an August keypad/)
+ fireEvent.click(warning)
+ await waitFor(() => {
+ expect(details?.hasAttribute('open')).toBe(false)
+ })
})
test('SupportedDevices shows features and keeps identifiers in model details', async (ctx) => {
@@ -121,7 +142,7 @@ test('SupportedDevices shows features and keeps identifier
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
const badges = within(row).getByRole('list', {
- name: 'Supported features',
+ name: 'Supported capabilities',
})
expect(
within(badges)
@@ -135,6 +156,87 @@ test('SupportedDevices shows features and keeps identifier
expect(within(row).queryByText(/via Schlage Home/)).toBeNull()
})
+for (const missingMetadata of [false, true]) {
+ test(`lost provider requirement text ${missingMetadata ? 'keeps a warning if metadata is unavailable' : 'is recovered from the matching provider'}`, async (ctx) => {
+ const fetch = globalThis.fetch
+ const providerRequests: URL[] = []
+ const fetchSpy = vi
+ .spyOn(globalThis, 'fetch')
+ .mockImplementation(async (input, init) => {
+ const url = new URL(String(input))
+ if (url.pathname.endsWith('providers/list')) {
+ providerRequests.push(url)
+ return new Response(
+ JSON.stringify({
+ providers: missingMetadata
+ ? []
+ : [
+ {
+ provider_id: 'august-access',
+ slug: 'august-access',
+ display_name: 'August Access',
+ has_requirements: true,
+ requirements_md: 'A paid subscription is required.',
+ },
+ ],
+ pagination: { has_next_page: false, next_page_cursor: null },
+ }),
+ { status: 200 }
+ )
+ }
+ const response = await fetch(input, init)
+ if (!url.pathname.endsWith('device_models/summaries/list'))
+ return response
+ const data = await response.json()
+ for (const model of data.device_models) {
+ for (const entry of model.capabilities_by_provider) {
+ for (const capability of entry.capabilities) {
+ if (capability.supported !== 'conditionally') continue
+ capability.report.requirements = {
+ ...capability.report.requirements,
+ provider: { message: 'Requirement message not found' },
+ }
+ }
+ }
+ }
+ return new Response(JSON.stringify(data), { status: 200 })
+ })
+ try {
+ renderWithEcosystem(
+ ,
+ ctx
+ )
+ const name = await screen.findByText('Smart Lock Pro')
+ const row = name.closest('li')
+ if (row == null) throw new Error('Missing row')
+ await waitFor(() => {
+ expect(providerRequests).toHaveLength(1)
+ })
+ expect(providerRequests[0]?.searchParams.get('slugs')).toBe(
+ 'august-access'
+ )
+ const text = await within(row).findByText(
+ missingMetadata
+ ? 'Additional integration requirements apply. Check with your integration provider.'
+ : 'A paid subscription is required.'
+ )
+ expect(text.closest('details')?.hasAttribute('open')).toBe(false)
+ const warning = within(row).getAllByRole('button', {
+ name: /requirements for/,
+ })[0]
+ if (warning == null) throw new Error('Missing warning control')
+ fireEvent.click(warning)
+ await waitFor(() => {
+ expect(text.closest('details')?.hasAttribute('open')).toBe(true)
+ })
+ within(row).getByText('ASL-03')
+ expect(screen.queryByText('Requirement message not found')).toBeNull()
+ } finally {
+ fetchSpy.mockRestore()
+ }
+ })
+}
+
test('SupportedDevices defaults to the manufacturer of a manufacturer key', async (ctx) => {
renderWithEcosystem(, {
...ctx,
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
index 008af7e57..c9123cd3d 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
@@ -11,7 +11,9 @@ import {
getDeviceTypeOptions,
getManufacturerName,
getManufacturerOptions,
+ getProvidersWithMissingRequirements,
groupDeviceModelsByManufacturer,
+ resolveProviderRequirements,
scopeDeviceModelsToProvider,
} from 'lib/seam/components/SupportedDevices/device-models.js'
import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect.js'
@@ -20,6 +22,7 @@ import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js'
import type { Manufacturer } from 'lib/seam/ecosystem/types.js'
import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js'
import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js'
+import { useEcosystemProviderRequirements } from 'lib/seam/ecosystem/use-ecosystem-provider-requirements.js'
import {
getEmbedKeyManufacturer,
useEmbedKeyResolution,
@@ -100,7 +103,7 @@ export function SupportedDevices({
enabled: (explicitManufacturer != null || isResolved) && !isResolutionError,
})
- const availableDeviceModels = useMemo(
+ const scopedDeviceModels = useMemo(
() =>
scopeDeviceModelsToProvider(
applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions),
@@ -109,6 +112,16 @@ export function SupportedDevices({
[deviceModels, embedResolution, fixedProvider]
)
+ const missingProviderSlugs = useMemo(
+ () => getProvidersWithMissingRequirements(scopedDeviceModels),
+ [scopedDeviceModels]
+ )
+ const { providers } = useEcosystemProviderRequirements(missingProviderSlugs)
+ const availableDeviceModels = useMemo(
+ () => resolveProviderRequirements(scopedDeviceModels, providers ?? []),
+ [scopedDeviceModels, providers]
+ )
+
const manufacturerSlugs = useMemo(
() =>
availableDeviceModels
@@ -403,7 +416,7 @@ const t = {
allCapabilities: 'All features',
featureStatus: 'Feature status',
supported: 'Supported',
- requiresSetup: 'Requires setup · see requirements below',
+ requiresSetup: 'Requires setup · expand details',
error: 'An error occurred while loading supported devices.',
retry: 'Retry',
noDevices: 'No supported devices found.',
diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts
index b9103eabf..43c0d64db 100644
--- a/src/lib/seam/components/SupportedDevices/device-models.test.ts
+++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts
@@ -12,8 +12,10 @@ import {
filterDeviceModels,
getCapabilityOptions,
getCapabilityRequirementMessages,
+ getProvidersWithMissingRequirements,
getSupportedCapabilities,
groupDeviceModelsByManufacturer,
+ resolveProviderRequirements,
scopeDeviceModelsToProvider,
} from './device-models.js'
@@ -297,6 +299,78 @@ test('requirement messages preserve specific constraints and flag unknown setup'
expect(
getCapabilityRequirementMessages({ ...generic, requirements: [] })
).toEqual(['Additional setup is required.'])
+ expect(
+ getCapabilityRequirementMessages({
+ ...generic,
+ details: 'Report details not found',
+ requirements: ['Requirement message not found'],
+ })
+ ).toEqual(['Additional setup is required.'])
+})
+
+test('provider requirements resolve by slug without changing valid messages or source reports', () => {
+ const model = deviceModel({
+ slug: 'lock',
+ capabilities: {
+ cloud: {
+ can_remotely_unlock: 'conditionally',
+ can_remotely_lock: 'conditionally',
+ },
+ hub: { can_remotely_unlock: 'conditionally' },
+ },
+ })
+ for (const entry of model.capabilities_by_provider) {
+ for (const capability of entry.capabilities) {
+ capability.report = {
+ details: '',
+ requirements: {
+ provider: {
+ message:
+ capability.name === 'can_remotely_lock'
+ ? 'Keep this specific requirement.'
+ : 'Requirement message not found',
+ },
+ bridge: { message: 'A compatible bridge is required.' },
+ },
+ }
+ }
+ }
+ expect(getProvidersWithMissingRequirements([model])).toEqual(['cloud', 'hub'])
+ const resolved = resolveProviderRequirements(
+ [model],
+ [
+ {
+ provider_id: 'cloud',
+ ...ref('cloud'),
+ has_requirements: true,
+ requirements_md: ' A paying SmartHosting subscription is required. ',
+ },
+ ]
+ )
+ expect(
+ resolved[0]?.capabilities_by_provider[0]?.capabilities[0]?.report
+ ?.requirements?.['provider']?.message
+ ).toBe('A paying SmartHosting subscription is required.')
+ expect(
+ resolved[0]?.capabilities_by_provider[0]?.capabilities[1]?.report
+ ?.requirements?.['provider']?.message
+ ).toBe('Keep this specific requirement.')
+ expect(
+ resolved[0]?.capabilities_by_provider[1]?.capabilities[0]?.report
+ ?.requirements?.['provider']?.message
+ ).toBe(
+ 'Additional integration requirements apply. Check with your integration provider.'
+ )
+ expect(
+ resolved[0]?.capabilities_by_provider[0]?.capabilities[0]?.report
+ ?.requirements?.['bridge']?.message
+ ).toBe('A compatible bridge is required.')
+ expect(
+ model.capabilities_by_provider[0]?.capabilities[0]?.report?.requirements?.[
+ 'provider'
+ ]?.message
+ ).toBe('Requirement message not found')
+ expect(getProvidersWithMissingRequirements(resolved)).toEqual([])
})
test('unavailable or stale capabilities never become supported badges or filters', () => {
diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts
index 7bd0bd51e..3a65e4874 100644
--- a/src/lib/seam/components/SupportedDevices/device-models.ts
+++ b/src/lib/seam/components/SupportedDevices/device-models.ts
@@ -6,6 +6,7 @@ import type {
IntegrationSupportLevel,
Manufacturer,
ManufacturerRef,
+ ProviderRequirements,
} from 'lib/seam/ecosystem/types.js'
export interface DeviceModelFilters {
@@ -192,14 +193,89 @@ const genericSupportDetails = new Set([
const isGenericSupportDetail = (details: string): boolean =>
genericSupportDetails.has(details.trim())
+const isMissingMessage = (message: unknown): boolean =>
+ typeof message !== 'string' ||
+ message.trim().length === 0 ||
+ message.trim() === 'Requirement message not found' ||
+ message.trim() === 'Report details not found'
+
+export const getProvidersWithMissingRequirements = (
+ models: DeviceModelSummary[]
+): string[] =>
+ [
+ ...new Set(
+ models.flatMap((model) =>
+ model.capabilities_by_provider
+ .filter((entry) =>
+ entry.capabilities.some(
+ (capability) =>
+ capability.report?.requirements?.['provider'] != null &&
+ isMissingMessage(
+ capability.report.requirements['provider'].message
+ )
+ )
+ )
+ .map((entry) => entry.provider.slug)
+ )
+ ),
+ ].sort()
+
+/** Legacy translation treats provider prose as a dictionary key; the public provider retains the prose. */
+export const resolveProviderRequirements = (
+ models: DeviceModelSummary[],
+ providers: ProviderRequirements[]
+): DeviceModelSummary[] => {
+ const bySlug = new Map(
+ providers.map((provider) => [provider.slug, provider.requirements_md])
+ )
+ return models.map((model) => ({
+ ...model,
+ capabilities_by_provider: model.capabilities_by_provider.map((entry) => ({
+ ...entry,
+ capabilities: entry.capabilities.map((capability) => {
+ const report = capability.report
+ const requirement = report?.requirements?.['provider']
+ if (
+ report == null ||
+ requirement == null ||
+ !isMissingMessage(requirement.message)
+ )
+ return capability
+ const message = bySlug.get(entry.provider.slug)
+ return {
+ ...capability,
+ report: {
+ ...report,
+ requirements: {
+ ...report.requirements,
+ provider: {
+ ...requirement,
+ message:
+ typeof message === 'string' && !isMissingMessage(message)
+ ? message.trim()
+ : 'Additional integration requirements apply. Check with your integration provider.',
+ },
+ },
+ },
+ }
+ }),
+ })),
+ }))
+}
+
/** Keep specific constraints; replace boilerplate with an actionable setup note. */
export const getCapabilityRequirementMessages = (
capability: SupportedCapability
): string[] => {
- const messages = new Set(capability.requirements)
+ const messages = new Set(
+ capability.requirements.map((message) =>
+ isMissingMessage(message) ? 'Additional setup is required.' : message
+ )
+ )
if (capability.supported === 'conditionally') {
if (
capability.details != null &&
+ !isMissingMessage(capability.details) &&
!isGenericSupportDetail(capability.details)
)
messages.add(capability.details)
diff --git a/src/lib/seam/ecosystem/types.ts b/src/lib/seam/ecosystem/types.ts
index 0ef934d26..e7c7d831c 100644
--- a/src/lib/seam/ecosystem/types.ts
+++ b/src/lib/seam/ecosystem/types.ts
@@ -182,6 +182,12 @@ export interface ProviderRef {
display_name: string | null
}
+/** Fields used from the public `providers/list` response to recover legacy report text. */
+export interface ProviderRequirements extends ProviderRef {
+ has_requirements: boolean | null
+ requirements_md: string | null
+}
+
export type CapabilityName =
| 'device_connectivity'
| 'can_program_online_access_codes'
@@ -391,6 +397,11 @@ export interface ManufacturersListResponse {
pagination: Pagination
}
+export interface ProvidersListResponse {
+ providers: ProviderRequirements[]
+ pagination: Pagination
+}
+
export interface EcosystemErrorResponse {
ok: false
error: {
diff --git a/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts b/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts
new file mode 100644
index 000000000..3c7437a30
--- /dev/null
+++ b/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts
@@ -0,0 +1,62 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { EcosystemApiError, fetchEcosystem } from 'lib/seam/ecosystem/client.js'
+import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js'
+import type {
+ ProviderRequirements,
+ ProvidersListResponse,
+} from 'lib/seam/ecosystem/types.js'
+import {
+ retryEcosystemQuery,
+ type UseEcosystemQueryResult,
+} from 'lib/seam/ecosystem/use-ecosystem-query-result.js'
+
+/** Only fetch providers whose legacy capability reports lost their free-text requirement. */
+export function useEcosystemProviderRequirements(
+ slugs: string[]
+): UseEcosystemQueryResult<'providers', ProviderRequirements[]> {
+ const { endpoint, embedKey } = useEcosystemContext()
+ const sortedSlugs = [...new Set(slugs)].sort()
+ const { data, ...rest } = useQuery(
+ {
+ enabled: sortedSlugs.length > 0,
+ queryKey: [
+ 'ecosystem',
+ endpoint,
+ embedKey ?? null,
+ 'providers',
+ 'list',
+ { slugs: sortedSlugs },
+ ],
+ queryFn: async ({ signal }) => {
+ const providers: ProviderRequirements[] = []
+ const seen = new Set()
+ let cursor: string | undefined
+ for (let page = 0; page < 10; page++) {
+ const res = await fetchEcosystem(
+ 'providers/list',
+ {
+ endpoint,
+ embedKey,
+ signal,
+ params: { slugs: sortedSlugs, limit: 500, page_cursor: cursor },
+ }
+ )
+ providers.push(...res.providers)
+ if (!res.pagination.has_next_page) return providers
+ const next = res.pagination.next_page_cursor
+ if (next == null || seen.has(next)) break
+ seen.add(next)
+ cursor = next
+ }
+ throw new EcosystemApiError({
+ status: 502,
+ type: 'pagination_incomplete',
+ message: 'The provider catalog returned incomplete requirements.',
+ })
+ },
+ retry: retryEcosystemQuery,
+ }
+ )
+ return { ...rest, providers: data }
+}
diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss
index 4685a4691..979aaeca9 100644
--- a/src/styles/_supported-devices.scss
+++ b/src/styles/_supported-devices.scss
@@ -152,10 +152,21 @@
font-size: 13px;
}
+ .seam-supported-devices-capabilities {
+ display: grid;
+ grid-column: 2;
+ gap: 4px;
+ }
+
+ .seam-supported-devices-capabilities-label {
+ color: var(--seam-ecosystem-fg-2);
+ font-size: 12px;
+ font-weight: 600;
+ }
+
.seam-supported-devices-features {
display: flex;
flex-wrap: wrap;
- grid-column: 2;
gap: 6px 16px;
margin: 0;
padding: 0;
@@ -179,6 +190,29 @@
}
}
+ .seam-supported-devices-requirement-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ margin: -4px 0 -4px -4px;
+ padding: 4px;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--seam-ecosystem-surface-2);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--seam-ecosystem-accent);
+ outline-offset: 2px;
+ }
+ }
+
.seam-supported-devices-section-footer {
display: flex;
justify-content: center;
@@ -197,7 +231,7 @@
.seam-supported-devices-requirements {
display: grid;
gap: 12px;
- margin: 4px 0;
+ margin: 12px 0;
padding: 12px;
border-radius: 8px;
background: var(--seam-ecosystem-surface-2);
diff --git a/vercel.json b/vercel.json
index c32173724..537459a2c 100644
--- a/vercel.json
+++ b/vercel.json
@@ -7,6 +7,10 @@
},
"cleanUrls": true,
"rewrites": [
+ {
+ "source": "/ecosystem/v1/providers/list",
+ "destination": "https://ecosystem.seam.co/v1/providers/list"
+ },
{
"source": "/ecosystem/v1/device_models/summaries/list",
"destination": "https://ecosystem.seam.co/v1/device_models/summaries/list"