diff --git a/.changeset/network-escape-batch2.md b/.changeset/network-escape-batch2.md new file mode 100644 index 0000000000..f80e7f7caa --- /dev/null +++ b/.changeset/network-escape-batch2.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change: the four network-escape files in batch 2 of objectui#7307 (three in `plugin-kanban`, one in `plugin-gantt`) now serve their `/api/v1/security/explain` probe from a recording double instead of a real socket, and their lines leave `KNOWN_ESCAPES` and `PINNED_LEDGER` together. No published behaviour changes — no product source is touched. diff --git a/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx b/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx index 28f37a60d3..e9ba5b8edc 100644 --- a/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx @@ -24,8 +24,8 @@ * renders", either of which passes in both worlds. */ import React from 'react'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ObjectGantt } from './ObjectGantt'; /** The width the gantt's drawer has always resolved to. Must not drift. */ @@ -79,12 +79,103 @@ async function openDrawer() { await waitFor(() => expect(drawerProps).not.toBeNull()); } +/* ───────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point (measured, not inferred): + * + * RecordDetailDrawer (the drawer this file opens) + * -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296 + * -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (twice per open: edit, then delete) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the + * record editable — fail open), which is why the cases below stayed green while + * the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on, carried + * by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and + * by this batch's sibling + * `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`. + * Deliberately NOT a blanket network stub: it records every URL it is handed + * and `afterEach` fails on any URL that is not the explain route, so an escape + * to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two explain hooks read — + * `{ record: { visible } }` for a single `recordId`, and + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the + * FIRST is reached from this file (every call measured above comes from + * `useRecordEditable`); the batched branch is kept so this router stays + * byte-identical to its siblings rather than forking per file. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, so `true` and the absent verdict the failing request + * produced are the same value at every read site. The drawer's width — + * everything this file asserts — is not derived from the verdict at all. + * ─────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + describe('gantt drawer width with no declared `navigation`', () => { beforeEach(() => { drawerProps = null; // Cross-test leakage guard: the drawer prefers a drag-resized width // persisted in localStorage over its prop, which would mask half 2. try { window.localStorage.clear(); } catch { /* ignore */ } + installExplainDouble(); + }); + afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); }); it('half 1: the gantt injects no width of its own (so the drawer default applies)', async () => { diff --git a/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx b/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx index cacc1f4607..3ad71b4a97 100644 --- a/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx @@ -109,12 +109,102 @@ function readPanelWidth(): string { return panel!.style.maxWidth; } +/* ───────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point (measured, not inferred): + * + * RecordDetailDrawer (the drawer this file opens) + * -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296 + * -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (twice per open: edit, then delete) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the + * record editable — fail open), which is why the cases below stayed green while + * the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on, carried + * by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and + * by this batch's sibling + * `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`. + * Deliberately NOT a blanket network stub: it records every URL it is handed + * and `afterEach` fails on any URL that is not the explain route, so an escape + * to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two explain hooks read — + * `{ record: { visible } }` for a single `recordId`, and + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the + * FIRST is reached from this file (every call measured above comes from + * `useRecordEditable`); the batched branch is kept so this router stays + * byte-identical to its siblings rather than forking per file. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, so `true` and the absent verdict the failing request + * produced are the same value at every read site. The drawer's width — + * everything this file asserts — is not derived from the verdict at all. + * ─────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + describe('kanban drawer width with no declared `navigation` (objectui#6303)', () => { beforeEach(() => { drawerProps = null; try { window.localStorage.clear(); } catch { /* ignore */ } + installExplainDouble(); + }); + afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); }); - afterEach(() => cleanup()); it('half 1: the kanban injects no width of its own (so the drawer default applies)', async () => { await openDrawer(); diff --git a/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx b/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx index af422034ce..5f205cab6e 100644 --- a/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx @@ -61,7 +61,7 @@ */ import React from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { I18nProvider } from '@object-ui/i18n'; @@ -109,7 +109,98 @@ async function openDrawer() { await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); } -afterEach(() => cleanup()); +/* ───────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point (measured, not inferred): + * + * RecordDetailDrawer (the drawer this file opens) + * -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296 + * -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (twice per open: edit, then delete) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the + * record editable — fail open), which is why the cases below stayed green while + * the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on, carried + * by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and + * by this batch's sibling + * `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`. + * Deliberately NOT a blanket network stub: it records every URL it is handed + * and `afterEach` fails on any URL that is not the explain route, so an escape + * to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two explain hooks read — + * `{ record: { visible } }` for a single `recordId`, and + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the + * FIRST is reached from this file (every call measured above comes from + * `useRecordEditable`); the batched branch is kept so this router stays + * byte-identical to its siblings rather than forking per file. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, so `true` and the absent verdict the failing request + * produced are the same value at every read site. The drawer's accessible + * name — everything this file asserts — is not derived from the verdict at all. + * ─────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + +beforeEach(() => installExplainDouble()); + +afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); +}); describe('ObjectKanban record-detail drawer heading (objectui#3459)', () => { it('names the drawer in English under an en session', async () => { diff --git a/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx index d0862f9a84..620ac35e32 100644 --- a/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx @@ -40,7 +40,7 @@ */ import React from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { registerAllFields } from '@object-ui/fields'; @@ -79,7 +79,98 @@ async function openDrawer() { await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); } -afterEach(() => cleanup()); +/* ───────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point (measured, not inferred): + * + * RecordDetailDrawer (the drawer this file opens) + * -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296 + * -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (twice per open: edit, then delete) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the + * record editable — fail open), which is why the cases below stayed green while + * the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on, carried + * by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and + * by this batch's sibling + * `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`. + * Deliberately NOT a blanket network stub: it records every URL it is handed + * and `afterEach` fails on any URL that is not the explain route, so an escape + * to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two explain hooks read — + * `{ record: { visible } }` for a single `recordId`, and + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the + * FIRST is reached from this file (every call measured above comes from + * `useRecordEditable`); the batched branch is kept so this router stays + * byte-identical to its siblings rather than forking per file. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, so `true` and the absent verdict the failing request + * produced are the same value at every read site. The drawer's accessible + * name — everything this file asserts — is not derived from the verdict at all. + * ─────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + +beforeEach(() => installExplainDouble()); + +afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); +}); describe('ObjectKanban drawer heading — English fallback with no provider (objectui#3459)', () => { it('interpolates the capitalized object name in English, never the raw key', async () => { diff --git a/scripts/__tests__/network-escape-ledger.test.ts b/scripts/__tests__/network-escape-ledger.test.ts index 9b73748213..623adf3f88 100644 --- a/scripts/__tests__/network-escape-ledger.test.ts +++ b/scripts/__tests__/network-escape-ledger.test.ts @@ -1,10 +1,10 @@ /** * The shrink-only pin for the network-escape ledger (objectui#6640). * - * `KNOWN_ESCAPES` in `vitest.setup.network-escape-guard.ts` records the 21 test - * files measured reaching a real socket on `67dadd6`. The guard's own docstring - * says that list "may only shrink" — and until this file existed, nothing made - * that true. An author who hit the guard's red could make it green by adding a + * `KNOWN_ESCAPES` in `vitest.setup.network-escape-guard.ts` records what remains + * of the 21 test files measured reaching a real socket on `67dadd6`. The guard's + * own docstring says that list "may only shrink" — and until this file existed, + * nothing made that true. An author who hit the guard's red could make it green by adding a * line, which is exactly how a burn-down ledger decays into the permanent * quarantine it is not supposed to be. THAT is the failure this pin prevents; * it does not re-measure escapes (that needs a real DOM run) and does not try. @@ -51,10 +51,6 @@ const PINNED_LEDGER: readonly string[] = [ 'packages/plugin-detail/src/__tests__/guideCrudAppRenders.test.tsx', 'packages/plugin-detail/src/__tests__/recordDetailsBodySource.test.tsx', 'packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx', - 'packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx', - 'packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx', - 'packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx', - 'packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx', ]; describe('network-escape ledger (objectui#6640) is shrink-only', () => { diff --git a/vitest.setup.network-escape-guard.ts b/vitest.setup.network-escape-guard.ts index 91fb6e50a3..4a500c9109 100644 --- a/vitest.setup.network-escape-guard.ts +++ b/vitest.setup.network-escape-guard.ts @@ -137,14 +137,6 @@ export const KNOWN_ESCAPES: ReadonlySet = new Set([ 'packages/plugin-detail/src/__tests__/recordDetailsBodySource.test.tsx', // /api/v1/security/explain 'packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx', - // /api/v1/security/explain - 'packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx', - // /api/v1/security/explain - 'packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx', - // /api/v1/security/explain - 'packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx', - // /api/v1/security/explain - 'packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx', ]); type Escape = { file: string; test: string; url: string };