diff --git a/.env.example b/.env.example index 16bf17b..db0906c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ -# NeKiro Control Plane Northbound API configuration. -VITE_NEKIRO_API_BASE_URL="http://127.0.0.1:18080" -VITE_NEKIRO_TOKEN="" -VITE_NEKIRO_OWNER_ID="" -VITE_NEKIRO_OWNER_NAME="" +# NeKiro public Gateway configuration. Every value must be supplied explicitly. +VITE_NEKIRO_API_BASE_URL="" +VITE_NEKIRO_PROVIDER_ID="" +VITE_NEKIRO_PROVIDER_NAME="" +VITE_NEKIRO_PROVIDER_TOKEN="" +VITE_NEKIRO_OWNER_TOKEN="" VITE_NEKIRO_DEFAULT_WORKSPACE_ID="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..365084d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: Console CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + frontend: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.3.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.16.0 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + - name: Test + run: pnpm run test + + - name: Build + run: pnpm run build + + browser-acceptance: + needs: frontend + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + # Deterministic CI-only fixtures. These credentials and signing values + # are never used as production configuration or emitted as artifacts. + POSTGRES_USER: nekiro_acceptance + POSTGRES_PASSWORD: acceptance-only-password + POSTGRES_DB: nekiro_acceptance + POSTGRES_PORT: "55432" + CONTROL_PLANE_PORT: "18080" + A2A_ROUTER_PORT: "18081" + NEKIRO_COMPOSE_DATABASE_URL: postgresql://nekiro_acceptance:acceptance-only-password@postgres:5432/nekiro_acceptance?sslmode=disable + NEKIRO_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"browser-provider","tokenSha256":"b44e989af5cb9bb1b315c9754b348747bc2adc400d20f413c96b0caf6cd76283"},{"id":"browser-owner","tokenSha256":"2db89754f354a623af6c54bb48473555bf68ae165f1d63001cb041e064078047"}]' + NEKIRO_INTERNAL_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"router-internal","tokenSha256":"f9232718425b5ebee721187a79703448bce513ecf0600eb161f9256ddac27c4d"}]' + NEKIRO_ROUTER_SERVICE_PRINCIPALS_JSON: '[{"id":"control-plane","tokenSha256":"5abfd00de27c6b2f57d45fdc90999134e4e088414ba1f39bf67ee0d1c9cec554"}]' + NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON: '[{"workspaceId":"workspace-browser","agentId":"runtime-a","tokenSha256":"e304d0370532633d535824a897d5c03445b636e8d1649064aa35a8fb50fef200"},{"workspaceId":"workspace-browser","agentId":"runtime-b","tokenSha256":"9b990de9bb74efd4e1d26a43a01e132deb60d563d49faf6878dca4af40858a38"}]' + NEKIRO_ROUTER_INTERNAL_BEARER_TOKEN: router-internal-token + NEKIRO_CONTROL_PLANE_SERVICE_TOKEN: control-plane-internal-token + NEKIRO_CORS_ALLOWED_ORIGINS: http://127.0.0.1:4173 + NEKIRO_ENDPOINT_CHALLENGE_TTL_SECONDS: "300" + NEKIRO_ENDPOINT_VERIFICATION_TIMEOUT_MS: "10000" + NEKIRO_ENDPOINT_ALLOWED_PRIVATE_HOSTS_JSON: '["runtime-a","runtime-b"]' + NEKIRO_CONTROL_PLANE_INTERNAL_REQUEST_MAX_BYTES: "1048576" + NEKIRO_GATEWAY_INVOCATION_REQUEST_MAX_BYTES: "1048576" + NEKIRO_GATEWAY_SSE_EVENT_MAX_BYTES: "65536" + NEKIRO_GATEWAY_METADATA_RESPONSE_MAX_BYTES: "1048576" + NEKIRO_GATEWAY_INVOCATION_DEADLINE_MS: "30000" + NEKIRO_ROUTER_INTERNAL_REQUEST_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_AGENT_REQUEST_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_CONTROL_PLANE_RESPONSE_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_AGENT_RESPONSE_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_A2A_EVENT_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_SSE_EVENT_LIMIT_BYTES: "65536" + NEKIRO_ROUTER_RESOLUTION_DEADLINE_MS: "30000" + NEKIRO_ROUTER_AGENT_DEADLINE_MS: "30000" + NEKIRO_ROUTER_AGENT_CREDENTIAL_ISSUER: https://a2a-router.nekiro.test + NEKIRO_ROUTER_AGENT_CREDENTIAL_KEY_ID: ci-browser-key-1 + NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8DoQe_884Qvh1w3RjnS8CZZ-TWMJulDV8d3IZkElUxuA + NEKIRO_ROUTER_AGENT_CREDENTIAL_TTL_SECONDS: "30" + NEKIRO_AGENT_ROUTER_ISSUER: https://a2a-router.nekiro.test + NEKIRO_AGENT_ROUTER_KEY_ID: ci-browser-key-1 + NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL: A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg + RUNTIME_A_ROUTER_TOKEN: runtime-a-router-token + RUNTIME_A_RESPONSE_LIMIT_BYTES: "1048576" + RUNTIME_A_EVENT_LIMIT_BYTES: "65536" + RUNTIME_B_ROUTER_TOKEN: runtime-b-router-token + RUNTIME_B_RESPONSE_LIMIT_BYTES: "1048576" + RUNTIME_B_EVENT_LIMIT_BYTES: "65536" + NEKIRO_E2E_BASE_URL: http://127.0.0.1:4173 + NEKIRO_E2E_COMPOSE_PROJECT: nekiro-console-browser + NEKIRO_E2E_COMPOSE_FILE: ${{ github.workspace }}/nekiro-platform/deploy/compose.yaml + VITE_NEKIRO_API_BASE_URL: http://gateway.nekiro.test:18080 + VITE_NEKIRO_PROVIDER_ID: browser-provider + VITE_NEKIRO_PROVIDER_NAME: Browser Provider + VITE_NEKIRO_PROVIDER_TOKEN: browser-provider-token + VITE_NEKIRO_OWNER_TOKEN: browser-owner-token + VITE_NEKIRO_DEFAULT_WORKSPACE_ID: workspace-browser + + steps: + - name: Check out Console + uses: actions/checkout@v4 + + - name: Check out platform acceptance dependency + uses: actions/checkout@v4 + with: + repository: NeKiro-project/NeKiro + ref: dec548e7b85da79c5982167688f0c698677d47c7 + path: nekiro-platform + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.3.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.16.0 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Map the Gateway to an explicit non-IP origin + run: echo "127.0.0.1 gateway.nekiro.test" | sudo tee -a /etc/hosts + + - name: Start fresh platform Compose + working-directory: nekiro-platform + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file deploy/compose.yaml up --build --detach --wait --wait-timeout 120 + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Build production Console with explicit browser configuration + run: pnpm run build + + - name: Run production browser acceptance + run: pnpm run test:e2e + + - name: Tear down fresh platform Compose + if: always() + working-directory: nekiro-platform + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file deploy/compose.yaml down --volumes --remove-orphans diff --git a/.gitignore b/.gitignore index 5a86d2a..54c07ea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ coverage/ *.log .env* !.env.example +test-results/ +playwright-report/ diff --git a/README.md b/README.md index ec9bf41..5c67755 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ This Console follows the MVP spec in docs/superpowers/specs/2026-07-16-nekiro-co - Registry: GET/POST /v3/agents, exact reads, publish, and disable for Agent Card v0.2. - Workspace: POST /v3/workspaces and GET /v3/workspaces/{workspaceId} from the header. - Installations: install, list, enable, disable, and uninstall through /v3/workspaces/{workspaceId}/installations. +- Trusted Publication: provider-owned Binding, Challenge, and immutable Release lifecycle through public /v4 Gateway routes. +- Installation trust handoff: an explicit Release ID is preflighted through GET /v4/releases/{releaseId}; Catalog publication alone is not trusted publication. ## Runtime surfaces @@ -17,20 +19,56 @@ This Console follows the MVP spec in docs/superpowers/specs/2026-07-16-nekiro-co - Ledger reads metadata-only Invocation and Trace projections through the same Workspace-scoped Gateway API. No fake traces, task streams, or timeout events are rendered. -- The browser uses one active Workspace and the development-static bearer token; - Agent authentication is declaration-only and never collects secrets. +- The canonical nested path is `Agent B -> Agent SDK -> A2A Router -> Agent A`. + The Console invokes B through Gateway v4 and reads the root/child lineage from + the metadata-only Ledger; it never accepts an Agent endpoint or runtime + credential in the form. +- The browser uses separate provider and Workspace-owner bearer contexts. Agent + authentication is declaration-only and never collects Agent secrets. ## Configuration -Create .env.local or export these values for local development: +Create .env.local or export these six values for local development: - VITE_NEKIRO_API_BASE_URL=http://127.0.0.1:18080 - VITE_NEKIRO_TOKEN= - VITE_NEKIRO_OWNER_ID= - VITE_NEKIRO_OWNER_NAME= + VITE_NEKIRO_API_BASE_URL=https://gateway.example.test + VITE_NEKIRO_PROVIDER_ID= + VITE_NEKIRO_PROVIDER_NAME= + VITE_NEKIRO_PROVIDER_TOKEN= + VITE_NEKIRO_OWNER_TOKEN= VITE_NEKIRO_DEFAULT_WORKSPACE_ID= -The bearer token is sent only as an Authorization header. It is not written to local storage. +`VITE_NEKIRO_PROVIDER_TOKEN` is used only for provider Catalog and Trusted +Publication operations. `VITE_NEKIRO_OWNER_TOKEN` is used only for Discovery, +Workspace, Installation, Invocation, and Ledger operations. Both are sent only +as Authorization headers and are not written to browser storage. + +The standalone repository is the upstream source for Console Issues #2/#4/#3. +The platform repository imports the reviewed runtime source into `apps/console` +and owns the root workspace lockfile and fresh-environment CI; do not maintain +a second hand-edited production Console implementation. + +The operational path is: + +1. Register an Agent Card with the provider context. +2. Create and verify an Endpoint Binding, then create, verify, and publish an immutable Release. +3. Give the Release ID to the Workspace owner. +4. Preflight that exact Release in Installations, accept permissions, and install the exact Card version. +5. Invoke only an enabled Installation that has a returned `installedReleaseId`. + +## Failure and recovery ownership + +The Console preserves the Gateway HTTP status, stable error code, trace ID, and +correlated invocation IDs where the contract returns them. Timeout, cancellation, +unavailable endpoint, disabled Installation, suspended/revoked Release, invalid +proof, expired/reused challenge, and malformed SSE are distinct failure +categories; the Console does not retry or turn them into success. + +Provider recovery is owned by the provider: issue a fresh challenge, repair the +declared endpoint, or create a new Release when the server requires it. Workspace +owner recovery is owned by the owner: review the exact Release ID and permissions, +enable or uninstall an Installation according to the server state, and submit a +new invocation only through an enabled trusted Installation. Router and Ledger +facts remain server-owned and are inspected through the public Gateway. ## Run locally @@ -40,7 +78,31 @@ The bearer token is sent only as an Authorization header. It is not written to l ## Verification npm test + npm run typecheck npm run lint npm run build rg "/v4/workspaces/.+invocations|/v4/workspaces/.+traces" src docs -n rg "INITIAL_AGENTS|INITIAL_INSTALLATIONS|TRACE_HISTORIES" src -n + +## Browser acceptance + +The browser acceptance suite runs against a fresh real Gateway and Compose +environment. It requires an explicitly installed Chromium and these values: + + NEKIRO_E2E_BASE_URL=http://127.0.0.1:4173 + NEKIRO_E2E_COMPOSE_FILE=/absolute/path/to/deploy/compose.yaml + NEKIRO_E2E_COMPOSE_PROJECT=nekiro-browser-acceptance + VITE_NEKIRO_API_BASE_URL=http://gateway.nekiro.test + VITE_NEKIRO_PROVIDER_ID=browser-provider + VITE_NEKIRO_PROVIDER_NAME=Browser Provider + VITE_NEKIRO_PROVIDER_TOKEN=... + VITE_NEKIRO_OWNER_TOKEN=... + VITE_NEKIRO_DEFAULT_WORKSPACE_ID=workspace-browser + +Build the production Console with the five VITE_NEKIRO_* values before +running npm run test:e2e. Missing or whitespace-padded values fail +configuration. The suite uses the Gateway only, creates server-backed state, +and never stores credentials or challenge proofs in browser storage. + +Playwright traces, screenshots, videos, and HTML reports are disabled for this +acceptance path and are ignored by Git if a local runner creates them. diff --git a/e2e/console.spec.ts b/e2e/console.spec.ts new file mode 100644 index 0000000..4a94e5f --- /dev/null +++ b/e2e/console.spec.ts @@ -0,0 +1,313 @@ +import {execFileSync} from 'node:child_process'; + +import {expect, test, type Locator, type Page} from '@playwright/test'; + +const providerId = required('VITE_NEKIRO_PROVIDER_ID'); +const workspaceId = required('VITE_NEKIRO_DEFAULT_WORKSPACE_ID'); +const composeFile = required('NEKIRO_E2E_COMPOSE_FILE'); +const composeProject = required('NEKIRO_E2E_COMPOSE_PROJECT'); + +type AgentFixture = { + id: string; + name: string; + endpoint: string; + service: string; + capability: string; +}; + +type ReleaseEvidence = { + releaseId: string; + cardDigest: string; +}; + +type BrowserLeakTracker = { + requestUrls: string[]; + requestBodies: string[]; + consoleMessages: string[]; +}; + +const runtimeA: AgentFixture = { + id: 'runtime-a', + name: 'Browser Runtime A', + endpoint: 'http://runtime-a:8091', + service: 'runtime-a', + capability: 'runtime.echo', +}; + +const runtimeB: AgentFixture = { + id: 'runtime-b', + name: 'Browser Runtime B', + endpoint: 'http://runtime-b:8092', + service: 'runtime-b', + capability: 'runtime.cross', +}; + +test.describe.configure({mode: 'serial'}); + +test('production Console completes trusted publication, invocation, trace, and isolated demos', async ({page}) => { + const apiRequests: string[] = []; + const requestUrls: string[] = []; + const requestBodies: string[] = []; + const consoleMessages: string[] = []; + const leakTracker: BrowserLeakTracker = {requestUrls, requestBodies, consoleMessages}; + page.on('request', (request) => { + requestUrls.push(request.url()); + if (request.postData()) requestBodies.push(request.postData() ?? ''); + if (/\/v[34]\//.test(request.url())) apiRequests.push(request.url()); + }); + page.on('console', (message) => consoleMessages.push(message.text())); + + await page.goto('/'); + await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); + await expect(page.getByText('API: configured', {exact: true})).toBeVisible(); + + await createWorkspace(page); + await registerCard(page, runtimeA); + await registerCard(page, runtimeB); + + const releaseA = await publishTrustedRelease(page, runtimeA, leakTracker); + const releaseB = await publishTrustedRelease(page, runtimeB, leakTracker); + + const ownerCatalogResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'GET' && url.pathname.endsWith('/v3/agents') && url.search === ''; + }); + await page.reload(); + const ownerCatalogResponse = await ownerCatalogResponsePromise; + expect(ownerCatalogResponse.status()).toBe(200); + const ownerCatalog = await ownerCatalogResponse.json() as { + items: Array<{card: {agentId: string; version: string}; publicationStatus: string}>; + }; + expect(ownerCatalog.items.map((item) => ({ + agentId: item.card.agentId, + version: item.card.version, + publicationStatus: item.publicationStatus, + })), 'Owner discovery must expose both published runtime Cards').toEqual(expect.arrayContaining([ + {agentId: runtimeA.id, version: '1.0.0', publicationStatus: 'published'}, + {agentId: runtimeB.id, version: '1.0.0', publicationStatus: 'published'}, + ])); + await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); + await installRelease(page, runtimeA, releaseA.releaseId); + await installRelease(page, runtimeB, releaseB.releaseId); + + await page.getByRole('button', {name: 'Installations', exact: true}).click(); + await page.getByLabel('Trusted Release ID', {exact: true}).fill('release-does-not-exist'); + const preflightResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/releases/release-does-not-exist') && response.request().method() === 'GET'); + await page.getByRole('button', {name: 'Preflight', exact: true}).click(); + const preflightResponse = await preflightResponsePromise; + expect(preflightResponse.status()).toBe(404); + const preflightError = await preflightResponse.json() as {code: string; traceId: string}; + expect(preflightError.code).toBe('NOT_FOUND'); + expect(preflightError.traceId).toBeTruthy(); + const preflightHeaderTrace = preflightResponse.headers()['x-nek-trace-id']; + if (preflightHeaderTrace !== undefined) expect(preflightHeaderTrace).toBe(preflightError.traceId); + await expect(page.getByText(/NOT_FOUND/)).toBeVisible(); + await expect(page.getByText(/HTTP 404/)).toBeVisible(); + await expect(page.getByText(new RegExp('traceId: ' + escapeRegExp(preflightError.traceId)))).toBeVisible(); + + await page.getByRole('button', {name: 'Invocations', exact: true}).click(); + const installationSelect = page.getByLabel('Installed Agent', {exact: true}); + await selectOptionContaining(installationSelect, runtimeB.id); + await page.getByLabel('Capability', {exact: true}).fill(runtimeB.capability); + await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'nested', value: {message: 'browser-json'}})); + await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + + const response = page.locator('pre').filter({hasText: 'invocationId'}).last(); + await expect(response).toContainText('runtime-a'); + const result = JSON.parse((await response.textContent()) ?? '{}') as {invocationId: string; rootTaskId: string; traceId: string}; + expect(result.invocationId).toBeTruthy(); + expect(result.rootTaskId).toBeTruthy(); + expect(result.traceId).toBeTruthy(); + + await page.getByRole('button', {name: 'Invocations', exact: true}).click(); + await selectOptionContaining(installationSelect, runtimeB.id); + await page.getByLabel('Capability', {exact: true}).fill(runtimeB.capability); + await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'stream-success', value: 'browser-sse'})); + await page.getByLabel('Stream result over SSE', {exact: true}).check(); + const sseResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":true')); + await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + const sseResponse = await sseResponsePromise; + expect(sseResponse.status()).toBe(200); + assertResultStream(await sseResponse.text()); + await expect(page.getByText('#0 accepted', {exact: true})).toBeVisible(); + await expect(page.getByText(/completed/, {exact: true}).last()).toBeVisible(); + + await page.getByRole('button', {name: 'Ledger', exact: true}).click(); + await page.getByLabel('Trace ID', {exact: true}).fill(result.traceId); + await page.getByRole('button', {name: 'Read', exact: true}).last().click(); + await expect(page.getByText(new RegExp(`${escapeRegExp(result.traceId)}`)).last()).toBeVisible(); + const ledgerText = await page.locator('main').innerText(); + expect(ledgerText).toContain(runtimeA.id); + expect(ledgerText).toContain(runtimeB.id); + expect(ledgerText).toContain(result.invocationId); + expect(ledgerText).toContain(releaseA.releaseId); + expect(ledgerText).toContain(releaseB.releaseId); + expect(ledgerText).toContain(releaseA.cardDigest); + expect(ledgerText).toContain(releaseB.cardDigest); + + apiRequests.length = 0; + for (const {hash, marker} of [ + {hash: '#/demo', marker: 'Three directions. Same data. Pick one.'}, + {hash: '#/demo/glass', marker: '6 cards'}, + {hash: '#/demo/terminal', marker: 'NEKIRO//OPS'}, + {hash: '#/demo/saas', marker: 'Find the right Agent for every workflow'}, + ]) { + await page.goto('/' + hash); + await expect(page.getByText(marker, {exact: true})).toBeVisible(); + } + expect(apiRequests).toEqual([]); +}); + +async function createWorkspace(page: Page): Promise { + const input = page.locator('input[placeholder="workspace id"]'); + await input.fill(workspaceId); + await page.getByRole('button', {name: 'Create workspace'}).click(); + await expect(page.getByText(`Workspace: ${workspaceId}`, {exact: true})).toBeVisible(); +} + +async function registerCard(page: Page, fixture: AgentFixture): Promise { + await page.getByRole('button', {name: 'Registry', exact: true}).click(); + await page.getByRole('button', {name: 'Register Agent Card', exact: true}).click(); + await page.getByLabel('Agent ID', {exact: true}).fill(fixture.id); + await page.getByLabel('Name', {exact: true}).fill(fixture.name); + await page.getByLabel('Owner ID', {exact: true}).fill(providerId); + await page.getByLabel('Owner display name', {exact: true}).fill('Browser Provider'); + await page.getByLabel('Version', {exact: true}).fill('1.0.0'); + await page.getByLabel('A2A endpoint', {exact: true}).fill(fixture.endpoint); + await page.getByLabel('Authentication', {exact: true}).selectOption('http_bearer'); + await page.getByLabel('Capabilities JSON', {exact: true}).fill(JSON.stringify({capabilities: [ + {id: fixture.capability, name: fixture.capability, description: 'Browser acceptance capability', inputSchema: {type: 'object'}, outputSchema: {type: 'object'}, requiredPermissions: []}, + ]}, null, 2)); + await page.getByRole('button', {name: 'Submit draft', exact: true}).click(); + await expect(page.getByText(fixture.id, {exact: true}).first()).toBeVisible(); +} + +async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTracker: BrowserLeakTracker): Promise { + await page.getByRole('button', {name: 'Trusted Publication', exact: true}).click(); + await page.getByRole('button', {name: new RegExp(escapeRegExp(fixture.id))}).first().click(); + await page.getByLabel('Agent endpoint', {exact: true}).fill(fixture.endpoint); + await page.getByRole('button', {name: 'Create Binding', exact: true}).click(); + await expect(page.getByText('pending', {exact: true}).last()).toBeVisible(); + + await page.getByRole('button', {name: 'Issue Challenge', exact: true}).click(); + const challengeId = await textMatching(page, /^challenge-[A-Za-z0-9._:-]+$/); + const proof = (await page.locator('code').last().textContent())?.trim(); + if (!proof) throw new Error('Console did not render the one-time challenge proof'); + const persistedValues = await page.evaluate(() => [ + ...Object.entries(localStorage), + ...Object.entries(sessionStorage), + ].flat()); + expect(persistedValues).not.toContain(proof); + injectChallengeProof(fixture.service, challengeId, proof); + await page.getByRole('button', {name: 'Complete Verification', exact: true}).click(); + await expect(page.getByText('verified', {exact: true}).last()).toBeVisible(); + await expect(page.locator('code')).toHaveCount(0); + expect(leakTracker.requestUrls.some((url) => url.includes(proof))).toBe(false); + expect(leakTracker.requestBodies.some((body) => body.includes(proof))).toBe(false); + expect(leakTracker.consoleMessages.some((message) => message.includes(proof))).toBe(false); + + await page.getByRole('button', {name: 'Create Release', exact: true}).click(); + const releaseSection = page.locator('section').filter({hasText: '3. Immutable Release'}); + const releaseState = releaseSection.getByText(/^(pending_verification|verified)$/, {exact: true}).last(); + await expect(releaseState).toBeVisible(); + if ((await releaseState.textContent()) === 'pending_verification') { + const verifyButton = releaseSection.getByRole('button', {name: 'Verify', exact: true}); + await expect(verifyButton).toBeEnabled(); + await verifyButton.click(); + } + await expect(releaseSection.getByText('verified', {exact: true}).last()).toBeVisible(); + await releaseSection.getByRole('button', {name: 'Publish', exact: true}).click(); + await expect(releaseSection.getByText('published', {exact: true}).last()).toBeVisible(); + + const releaseId = await readFactValue(releaseSection, 'Release'); + const cardDigest = await readFactValue(releaseSection, 'Card digest'); + if (!/^[A-Za-z0-9._:-]+$/.test(releaseId) || !/^[0-9a-f]{64}$/.test(cardDigest)) { + throw new Error('Console did not render immutable Release provenance'); + } + return {releaseId, cardDigest}; +} + +async function installRelease(page: Page, fixture: AgentFixture, releaseId: string): Promise { + await page.getByRole('button', {name: 'Installations', exact: true}).click(); + const agentSelect = page.getByLabel('Published Agent', {exact: true}); + await selectOptionContaining(agentSelect, fixture.id); + await page.getByLabel('Trusted Release ID', {exact: true}).fill(releaseId); + await page.getByRole('button', {name: 'Preflight', exact: true}).click(); + await expect(page.getByText('Published Release preflight passed', {exact: true})).toBeVisible(); + await page.getByRole('button', {name: 'Install exact pin', exact: true}).click(); + await expect(page.getByText(releaseId, {exact: true}).last()).toBeVisible(); +} + +async function selectOptionContaining(select: Locator, text: string): Promise { + await expect.poll( + async () => select.locator('option').evaluateAll((options, wanted) => options.some((item) => { + const option = item as HTMLOptionElement; + return option.textContent?.includes(String(wanted)) || option.value.includes(String(wanted)); + }), text), + {message: `Expected an Agent option containing ${text}`}, + ).toBe(true); + const value = await select.locator('option').evaluateAll((options, wanted) => { + const option = options.find((item) => { + const candidate = item as HTMLOptionElement; + return candidate.textContent?.includes(String(wanted)) || candidate.value.includes(String(wanted)); + }); + if (!option) throw new Error(`No select option contains ${String(wanted)}`); + return (option as HTMLOptionElement).value; + }, text); + await select.selectOption(value); +} + +async function textMatching(page: Page, pattern: RegExp): Promise { + const value = (await page.getByText(pattern).last().textContent())?.trim(); + if (!value) throw new Error(`Console did not render text matching ${pattern}`); + return value; +} + +async function readFactValue(section: Locator, label: string): Promise { + const labelElement = section.getByText(label, {exact: true}); + return ((await labelElement.locator('..').locator('div').nth(1).textContent()) ?? '').trim(); +} + +function injectChallengeProof(service: string, challengeId: string, proof: string): void { + execFileSync('docker', [ + 'compose', '--project-name', composeProject, '--file', composeFile, + 'exec', '-T', service, 'sh', '-c', + 'umask 077; cat > "$NEKIRO_AGENT_CHALLENGE_DIRECTORY/$1"', 'sh', challengeId, + ], {input: proof, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe']}); +} + +function assertResultStream(body: string): void { + const events = body.trim().split(/\r?\n\r?\n/).filter(Boolean).map((block) => { + const line = block.split(/\r?\n/).find((value) => value.startsWith('data: ')); + if (!line) throw new Error('SSE response omitted a data line'); + return JSON.parse(line.slice('data: '.length)) as { + schemaVersion: string; + sequence: number; + type: string; + status: string; + invocationId: string; + rootTaskId: string; + traceId: string; + }; + }); + if (events.length < 2) throw new Error('SSE response did not contain accepted and terminal events'); + const first = events[0]; + const last = events[events.length - 1]; + if (first.type !== 'accepted' || first.status !== 'pending' || first.sequence !== 0) throw new Error('SSE response did not begin with accepted/pending sequence 0'); + events.forEach((event, index) => { + if (event.schemaVersion !== '2' || event.sequence !== index || event.invocationId !== first.invocationId || event.rootTaskId !== first.rootTaskId || event.traceId !== first.traceId) { + throw new Error('SSE response correlation or sequence changed'); + } + }); + if (last.type !== 'completed' || last.status !== 'succeeded') throw new Error('SSE response did not end with completed/succeeded'); +} + +function required(name: string): string { + const value = process.env[name]; + if (!value || value !== value.trim()) throw new Error(`${name} is required and must not contain surrounding whitespace`); + return value; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/package.json b/package.json index 4b47c9e..14224e5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,10 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist server.js", + "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", - "test": "tsx --test" + "test": "tsx --test src/api/nekiro.test.ts src/consoleConfig.test.ts src/consolePolicy.test.ts src/components/consoleSurface.test.tsx src/demos/routing.test.ts", + "test:e2e": "playwright test" }, "dependencies": { "@tailwindcss/vite": "^4.1.14", @@ -23,6 +25,7 @@ "devDependencies": { "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", + "@playwright/test": "^1.52.0", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4bdd999 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import {defineConfig} from '@playwright/test'; + +const baseURL = process.env.NEKIRO_E2E_BASE_URL; +if (!baseURL || baseURL !== baseURL.trim() || !/^https?:\/\/[^/]+(?::\d+)?$/.test(baseURL)) { + throw new Error('NEKIRO_E2E_BASE_URL must be an explicit browser server origin'); +} + +if (!process.env.NEKIRO_E2E_COMPOSE_FILE || !process.env.NEKIRO_E2E_COMPOSE_PROJECT) { + throw new Error('NEKIRO_E2E_COMPOSE_FILE and NEKIRO_E2E_COMPOSE_PROJECT are required'); +} + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + timeout: 120_000, + expect: {timeout: 15_000}, + reporter: 'line', + use: { + baseURL, + browserName: 'chromium', + trace: 'off', + screenshot: 'off', + video: 'off', + }, + webServer: { + command: 'npm run preview -- --host 127.0.0.1 --port 4173', + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..27dd70e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1890 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.14 + version: 4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.2.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + lucide-react: + specifier: ^0.546.0 + version: 0.546.0(react@19.2.8) + motion: + specifier: ^12.23.24 + version: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.0.1 + version: 19.2.8 + react-dom: + specifier: ^19.0.1 + version: 19.2.8(react@19.2.8) + vite: + specifier: ^6.2.3 + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.62.0 + '@types/node': + specifier: ^22.14.0 + version: 22.20.1 + autoprefixer: + specifier: ^10.4.21 + version: 10.5.4(postcss@8.5.23) + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + tailwindcss: + specifier: ^4.1.14 + version: 4.3.3 + tsx: + specifier: ^4.21.0 + version: 4.23.1 + typescript: + specifier: ~5.8.2 + version: 5.8.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.396: + resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + + enhanced-resolve@5.24.3: + resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + engines: {node: '>=10.13.0'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.546.0: + resolution: {integrity: sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.42.2: + resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitejs/plugin-react@5.2.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + transitivePeerDependencies: + - supports-color + + autoprefixer@10.5.4(postcss@8.5.23): + dependencies: + browserslist: 4.28.7 + caniuse-lite: 1.0.30001806 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.23 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.11.1: {} + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.396 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + caniuse-lite@1.0.30001806: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.396: {} + + enhanced-resolve@5.24.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fraction.js@5.3.4: {} + + framer-motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.546.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.23: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.23 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + tsx: 4.23.1 + + yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/App.tsx b/src/App.tsx index 1fa3025..72da54b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,30 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {AnimatePresence, motion} from 'motion/react'; import {CheckCircle2, Cpu, HelpCircle, ShieldAlert, X} from 'lucide-react'; -import {mapCatalogEntry, NekiroApiClient, toPlatformErrorView, type AgentCardV02} from './api/nekiro'; +import {mapCatalogEntry, NekiroApiClient, NekiroApiError, toPlatformErrorView, validateTrustedInstallation, type AgentCardV02, type AgentRelease} from './api/nekiro'; +import {agentKey, isCurrentRequest, matchesPublishedRelease, nextRequestGeneration} from './consolePolicy'; import Header from './components/Header'; import InstallationsTab from './components/InstallationsTab'; import InvocationsTab from './components/InvocationsTab'; import LedgerTab from './components/LedgerTab'; import RegistryTab from './components/RegistryTab'; import Sidebar from './components/Sidebar'; +import TrustedPublicationTab from './components/TrustedPublicationTab'; +import {requireConsoleConfiguration} from './consoleConfig'; import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from './types'; export default function App() { - const [activeTab, setActiveTab] = useState<'registry' | 'installations' | 'invocations' | 'ledger'>('registry'); + requireConsoleConfiguration(import.meta.env); + const [activeTab, setActiveTab] = useState<'registry' | 'trusted' | 'installations' | 'invocations' | 'ledger'>('registry'); const [searchQuery, setSearchQuery] = useState(''); const [agents, setAgents] = useState([]); + const [providerAgents, setProviderAgents] = useState([]); const [draftAgents, setDraftAgents] = useState([]); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogError, setCatalogError] = useState(null); const [catalogReady, setCatalogReady] = useState(false); + const [providerCatalogError, setProviderCatalogError] = useState(null); const [workspace, setWorkspace] = useState(null); const [workspaceDraft, setWorkspaceDraft] = useState(import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID ?? ''); const [workspaceLoading, setWorkspaceLoading] = useState(false); @@ -26,51 +32,86 @@ export default function App() { const [installations, setInstallations] = useState([]); const [installationLoading, setInstallationLoading] = useState(false); const [installationError, setInstallationError] = useState(null); - const [pendingInstallAgentId, setPendingInstallAgentId] = useState(); const [showSettings, setShowSettings] = useState(false); const [showSupport, setShowSupport] = useState(false); + const catalogRequestGeneration = useRef(0); + const providerCatalogRequestGeneration = useRef(0); + const workspaceRequestGeneration = useRef(0); + const installationRequestGeneration = useRef(0); - const nekiroClient = useMemo( + const providerClient = useMemo( () => new NekiroApiClient({ baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, - token: import.meta.env.VITE_NEKIRO_TOKEN, + token: import.meta.env.VITE_NEKIRO_PROVIDER_TOKEN, + }), + [], + ); + const ownerClient = useMemo( + () => new NekiroApiClient({ + baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, + token: import.meta.env.VITE_NEKIRO_OWNER_TOKEN, }), [], ); const loadAgents = useCallback(async (query = '') => { + const generation = nextRequestGeneration(catalogRequestGeneration.current); + catalogRequestGeneration.current = generation; setCatalogLoading(true); setCatalogError(null); try { - const response = await nekiroClient.searchAgents(query.trim() ? {query: query.trim()} : undefined); + const response = await ownerClient.searchAgents(query.trim() ? {query: query.trim()} : undefined); + if (!isCurrentRequest(generation, catalogRequestGeneration.current)) return; setAgents(response.items.map(mapCatalogEntry)); setCatalogReady(true); } catch (error) { + if (!isCurrentRequest(generation, catalogRequestGeneration.current)) return; setCatalogError(toPlatformErrorView(error, 'Unable to load the NeKiro Catalog.')); } finally { - setCatalogLoading(false); + if (isCurrentRequest(generation, catalogRequestGeneration.current)) setCatalogLoading(false); + } + }, [ownerClient]); + + const loadProviderAgents = useCallback(async (query = '') => { + const generation = nextRequestGeneration(providerCatalogRequestGeneration.current); + providerCatalogRequestGeneration.current = generation; + setProviderCatalogError(null); + try { + const providerId = import.meta.env.VITE_NEKIRO_PROVIDER_ID; + const response = await providerClient.searchAgents({ownerId: providerId, ...(query.trim() ? {query: query.trim()} : {})}); + if (!isCurrentRequest(generation, providerCatalogRequestGeneration.current)) return; + setProviderAgents(response.items.map(mapCatalogEntry).filter((agent) => agent.ownerId === providerId)); + } catch (error) { + if (!isCurrentRequest(generation, providerCatalogRequestGeneration.current)) return; + setProviderCatalogError(toPlatformErrorView(error, 'Unable to load provider-owned Agent Cards.')); } - }, [nekiroClient]); + }, [providerClient]); const loadWorkspace = useCallback(async (workspaceId: string) => { + const generation = nextRequestGeneration(workspaceRequestGeneration.current); + workspaceRequestGeneration.current = generation; setWorkspaceLoading(true); setWorkspaceError(null); try { - const value = await nekiroClient.getWorkspace(workspaceId); + const value = await ownerClient.getWorkspace(workspaceId); + if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return null; setWorkspace(value); setWorkspaceDraft(value.workspaceId); return value; } catch (error) { + if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return null; setWorkspace(null); setInstallations([]); setWorkspaceError(toPlatformErrorView(error, 'Unable to load Workspace.')); return null; } finally { - setWorkspaceLoading(false); + if (isCurrentRequest(generation, workspaceRequestGeneration.current)) setWorkspaceLoading(false); } - }, [nekiroClient]); + }, [ownerClient]); const loadInstallations = useCallback(async (workspaceId = workspace?.workspaceId) => { + const generation = nextRequestGeneration(installationRequestGeneration.current); + installationRequestGeneration.current = generation; if (!workspaceId) { setInstallations([]); return; @@ -78,21 +119,24 @@ export default function App() { setInstallationLoading(true); setInstallationError(null); try { - const response = await nekiroClient.listInstallations(workspaceId, {limit: 100}); + const response = await ownerClient.listInstallations(workspaceId, {limit: 100}); + if (!isCurrentRequest(generation, installationRequestGeneration.current)) return; setInstallations(response.items); } catch (error) { + if (!isCurrentRequest(generation, installationRequestGeneration.current)) return; setInstallationError(toPlatformErrorView(error, 'Unable to load Workspace Installations.')); } finally { - setInstallationLoading(false); + if (isCurrentRequest(generation, installationRequestGeneration.current)) setInstallationLoading(false); } - }, [nekiroClient, workspace?.workspaceId]); + }, [ownerClient, workspace?.workspaceId]); useEffect(() => { const timer = window.setTimeout(() => { void loadAgents(searchQuery); + void loadProviderAgents(searchQuery); }, 250); return () => window.clearTimeout(timer); - }, [loadAgents, searchQuery]); + }, [loadAgents, loadProviderAgents, searchQuery]); useEffect(() => { const defaultWorkspaceId = import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID; @@ -109,7 +153,7 @@ export default function App() { setWorkspaceLoading(true); setWorkspaceError(null); try { - const value = await nekiroClient.createWorkspace(workspaceDraft); + const value = await ownerClient.createWorkspace(workspaceDraft); setWorkspace(value); setWorkspaceDraft(value.workspaceId); await loadInstallations(value.workspaceId); @@ -128,39 +172,32 @@ export default function App() { }; const handleRegisterAgent = async (card: AgentCardV02) => { - const entry = await nekiroClient.registerAgent(card); + const entry = await providerClient.registerAgent(card); const draftAgent = mapCatalogEntry(entry); setDraftAgents((current) => upsertAgent(current, draftAgent)); - await loadAgents(searchQuery); + await Promise.all([loadAgents(searchQuery), loadProviderAgents(searchQuery)]); return draftAgent; }; const handlePublishAgent = async (agent: Agent) => { - await nekiroClient.publishAgentVersion(agent.id, agent.version); + await providerClient.publishAgentVersion(agent.id, agent.version); setDraftAgents((current) => current.filter((draft) => agentKey(draft) !== agentKey(agent))); - await loadAgents(searchQuery); - }; - - const handleDisableAgent = async (agent: Agent) => { - await nekiroClient.disableAgentVersion(agent.id, agent.version); - await loadAgents(searchQuery); + await Promise.all([loadAgents(searchQuery), loadProviderAgents(searchQuery)]); }; - const handleOpenInstall = (agent: Agent) => { - setPendingInstallAgentId(agent.id); - setActiveTab('installations'); - setSearchQuery(''); - }; - - const handleInstallAgent = async (agent: Agent, versionConstraint: string, acceptedPermissions: string[]) => { + const handleInstallAgent = async (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => { if (!workspace) { throw new Error('Select or create a Workspace before installing an Agent.'); } - await nekiroClient.installAgent(workspace.workspaceId, { + if (!matchesPublishedRelease(release, agent)) { + throw new NekiroApiError(0, 'The selected Release is not a published match for the selected Agent Card.', 'INVALID_RESPONSE'); + } + const installation = await ownerClient.installAgent(workspace.workspaceId, { agentId: agent.id, - versionConstraint, + versionConstraint: release.agentCardVersion, acceptedPermissions, }); + validateTrustedInstallation(installation, release, agent.id); await loadInstallations(workspace.workspaceId); }; @@ -170,7 +207,7 @@ export default function App() { } setInstallationError(null); try { - await nekiroClient.updateInstallation(workspace.workspaceId, installation.installationId, status); + await ownerClient.updateInstallation(workspace.workspaceId, installation.installationId, status); await loadInstallations(workspace.workspaceId); } catch (error) { setInstallationError(toPlatformErrorView(error, 'Unable to update Installation.')); @@ -179,14 +216,16 @@ export default function App() { const handleUninstall = async (installation: Installation) => { if (!workspace) { - return; + return false; } setInstallationError(null); try { - await nekiroClient.uninstallAgent(workspace.workspaceId, installation.installationId); + await ownerClient.uninstallAgent(workspace.workspaceId, installation.installationId); await loadInstallations(workspace.workspaceId); + return true; } catch (error) { setInstallationError(toPlatformErrorView(error, 'Unable to uninstall Agent.')); + return false; } }; @@ -194,6 +233,8 @@ export default function App() { switch (activeTab) { case 'registry': return 'Search agent name, description, capability...'; + case 'trusted': + return 'Filter registered Agent Cards...'; case 'installations': return 'Search installation id, agent id, pinned version...'; case 'invocations': @@ -233,8 +274,8 @@ export default function App() { workspaceError={workspaceError} onReadWorkspace={handleReadWorkspace} onCreateWorkspace={handleCreateWorkspace} - userLabel={import.meta.env.VITE_NEKIRO_OWNER_NAME ?? import.meta.env.VITE_NEKIRO_OWNER_ID ?? ''} - apiConfigured={Boolean(import.meta.env.VITE_NEKIRO_API_BASE_URL)} + userLabel={workspace?.ownerId ?? 'Workspace owner'} + apiConfigured={Boolean(import.meta.env.VITE_NEKIRO_API_BASE_URL && import.meta.env.VITE_NEKIRO_PROVIDER_ID && import.meta.env.VITE_NEKIRO_PROVIDER_TOKEN && import.meta.env.VITE_NEKIRO_OWNER_TOKEN && import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID)} />
@@ -246,18 +287,29 @@ export default function App() { draftAgents={draftAgents} onRegisterAgent={handleRegisterAgent} onPublishAgent={handlePublishAgent} - onDisableAgent={handleDisableAgent} - onOpenInstall={handleOpenInstall} catalogLoading={catalogLoading} catalogError={catalogError} catalogReady={catalogReady} - defaultOwnerId={import.meta.env.VITE_NEKIRO_OWNER_ID ?? ''} - defaultOwnerName={import.meta.env.VITE_NEKIRO_OWNER_NAME ?? ''} + defaultOwnerId={import.meta.env.VITE_NEKIRO_PROVIDER_ID ?? ''} + defaultOwnerName={import.meta.env.VITE_NEKIRO_PROVIDER_NAME ?? ''} searchQuery={searchQuery} /> )} + {activeTab === 'trusted' && ( + + void loadProviderAgents(searchQuery)} + /> + + )} + {activeTab === 'installations' && ( - + )} {activeTab === 'ledger' && ( - + )} @@ -294,7 +346,8 @@ export default function App() { } onClose={() => setShowSettings(false)}>

Base URL: {import.meta.env.VITE_NEKIRO_API_BASE_URL || 'not configured'}

-

Token source: VITE_NEKIRO_TOKEN (never persisted in local storage)

+

Provider context: VITE_NEKIRO_PROVIDER_ID + VITE_NEKIRO_PROVIDER_TOKEN

+

Workspace owner context: VITE_NEKIRO_OWNER_TOKEN (credentials are never persisted in local storage)

Default Workspace: {import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID || 'manual selection'}

@@ -339,7 +392,3 @@ function Overlay({title, icon, children, onClose}: {title: string; icon: React.R function upsertAgent(agents: Agent[], next: Agent): Agent[] { return [next, ...agents.filter((agent) => agentKey(agent) !== agentKey(next))]; } - -function agentKey(agent: Agent): string { - return agent.id + '@' + agent.version; -} diff --git a/src/api/nekiro.test.ts b/src/api/nekiro.test.ts index e6b30a4..3150d28 100644 --- a/src/api/nekiro.test.ts +++ b/src/api/nekiro.test.ts @@ -6,7 +6,9 @@ import { mapCatalogEntry, NekiroApiClient, NekiroApiError, + validateTrustedInstallation, type AgentCardV02, + type AgentRelease, type CatalogEntry, } from './nekiro'; @@ -176,15 +178,99 @@ test('NekiroApiClient covers Workspace and Installation v3 paths', async () => { token: 'test-token', fetchImpl: async (input, init) => { requests.push({url: String(input), init}); - return new Response(JSON.stringify({items: []}), {status: 200}); + return new Response(JSON.stringify({items: [{ + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '1.2.3', installedVersion: '1.2.3', installedReleaseId: 'release-1', acceptedPermissions: [], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }]}), {status: 200}); }, }); - await client.listInstallations('workspace.alpha', {limit: 50, cursor: 'next'}); + const result = await client.listInstallations('workspace.alpha', {limit: 50, cursor: 'next'}); + assert.equal(result.items[0]?.installedReleaseId, 'release-1'); assert.equal(requests[0]?.url, 'https://api.example.test/v3/workspaces/workspace.alpha/installations?limit=50&cursor=next'); }); +test('NekiroApiClient strictly maps every Installation read response', async () => { + const installation = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '1.2.3', installedVersion: '1.2.3', installedReleaseId: 'release-1', acceptedPermissions: [], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + let response: Record = installation; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', token: 'owner-token', + fetchImpl: async () => new Response(JSON.stringify(response), {status: 200}), + }); + assert.equal((await client.getInstallation('workspace.alpha', 'installation-1')).installationId, 'installation-1'); + assert.equal((await client.updateInstallation('workspace.alpha', 'installation-1', 'disabled')).status, 'enabled'); + assert.equal((await client.uninstallAgent('workspace.alpha', 'installation-1')).installedReleaseId, 'release-1'); + response = {...installation, unexpected: true}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /unknown field/); +}); + +test('NekiroApiClient enforces Installation v2 semantic response rules', async () => { + const base = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '^1.0.0', installedVersion: '1.2.3', acceptedPermissions: ['read', 'write'], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + let response: Record = base; + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'owner-token', fetchImpl: async () => new Response(JSON.stringify(response), {status: 200})}); + assert.equal((await client.getInstallation('workspace.alpha', 'installation-1')).installedVersion, '1.2.3'); + response = {...base, installedVersion: '2.0.0'}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /does not satisfy/); + response = {...base, status: 'uninstalled', uninstalledAt: '2026-07-26T00:01:00Z'}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /uninstalledAt must equal/); + response = {...base, installedAt: '2026-07-26T00:01:00Z', updatedAt: '2026-07-25T00:00:00Z'}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /must not precede/); +}); + +test('NekiroApiClient installs an exact trusted version and preserves Release provenance', async () => { + const requests: Array<{url: string; init?: RequestInit}> = []; + const installation = { + installationId: 'installation-1', + workspaceId: 'workspace.alpha', + agentId: 'agent.echo', + versionConstraint: '1.2.3', + installedVersion: '1.2.3', + installedReleaseId: 'release-1', + acceptedPermissions: [], + status: 'enabled', + installedAt: '2026-07-26T00:00:00Z', + updatedAt: '2026-07-26T00:00:00Z', + }; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'owner-token', + fetchImpl: async (input, init) => { + requests.push({url: String(input), init}); + return new Response(JSON.stringify(installation), {status: 201}); + }, + }); + const result = await client.installAgent('workspace.alpha', {agentId: 'agent.echo', versionConstraint: '1.2.3', acceptedPermissions: []}); + assert.equal(result.installedReleaseId, 'release-1'); + assert.equal(requests[0]?.url, 'https://api.example.test/v3/workspaces/workspace.alpha/installations'); + assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {agentId: 'agent.echo', versionConstraint: '1.2.3', acceptedPermissions: []}); +}); + +test('trusted Installation validation rejects missing Release identity or non-enabled state', () => { + const release = trustedRelease() as unknown as AgentRelease; + const installation = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', + versionConstraint: '1.2.3', installedVersion: '1.2.3', installedReleaseId: 'release-1', + acceptedPermissions: [], status: 'enabled' as const, installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + assert.doesNotThrow(() => validateTrustedInstallation(installation, release, 'agent.echo')); + assert.throws(() => validateTrustedInstallation({...installation, installedReleaseId: undefined}, release, 'agent.echo'), /Release identity/); + assert.throws(() => validateTrustedInstallation({...installation, status: 'disabled'}, release, 'agent.echo'), /Release identity/); +}); + +test('provider and Workspace-owner clients keep bearer contexts separate', async () => { + const authorization: string[] = []; + const response = trustedResponse(trustedRelease(), 200); + const providerClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'provider-token', fetchImpl: async (_input, init) => { authorization.push(new Headers(init?.headers).get('Authorization') ?? ''); return response.clone(); }}); + const ownerClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'owner-token', fetchImpl: async (_input, init) => { authorization.push(new Headers(init?.headers).get('Authorization') ?? ''); return response.clone(); }}); + await providerClient.getAgentRelease('release-1'); + await ownerClient.getAgentRelease('release-1'); + assert.deepEqual(authorization, ['Bearer provider-token', 'Bearer owner-token']); +}); + test('NekiroApiClient constructs a strict v4 JSON invocation request', async () => { const requests: Array<{url: string; init?: RequestInit}> = []; const client = new NekiroApiClient({ @@ -230,6 +316,14 @@ test('NekiroApiClient reads Workspace-scoped v4 Invocation and Trace paths', asy assert.deepEqual(requests, ['https://api.example.test/v4/workspaces/workspace.alpha/invocations/inv-1', 'https://api.example.test/v4/workspaces/workspace.alpha/traces/trace-1']); }); +test('NekiroApiClient rejects Invocation Detail provenance changes', async () => { + const cardDigest = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const record = {invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', caller: {type: 'user', id: 'owner-a'}, workspaceId: 'workspace.alpha', targetAgentId: 'runtime.echo', agentCardVersion: '1.0.0', agentReleaseId: 'release-1', agentCardDigest: cardDigest, capability: 'runtime.echo', status: 'pending', createdAt: '2026-07-21T00:00:00Z', updatedAt: '2026-07-21T00:00:00Z'}; + const event = {schemaVersion: '0.3', eventId: 'evt-1', sequence: 0, occurredAt: '2026-07-21T00:00:00Z', type: 'created', status: 'pending', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', caller: {type: 'user', id: 'owner-a'}, workspaceId: 'workspace.alpha', targetAgentId: 'runtime.echo', agentCardVersion: '1.0.0', agentReleaseId: 'release-1', agentCardDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', capability: 'runtime.echo'}; + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(JSON.stringify({invocation: record, events: [event]}), {status: 200})}); + await assert.rejects(() => client.getInvocation('workspace.alpha', 'inv-1'), /Invocation Detail event correlation is invalid/); +}); + test('NekiroApiClient validates ordered SSE events and requires a terminal event', async () => { const accepted = {schemaVersion: '2', sequence: 0, type: 'accepted', status: 'pending', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1'}; const completed = {schemaVersion: '2', sequence: 1, type: 'completed', status: 'succeeded', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1'}; @@ -267,6 +361,9 @@ test('NekiroApiClient rejects omitted Installation limits instead of inventing o test('NekiroApiClient rejects missing or whitespace bearer configuration', () => { assert.throws(() => new NekiroApiClient({baseUrl: 'https://api.example.test', token: ''}), /bearer token is required/); assert.throws(() => new NekiroApiClient({baseUrl: 'https://api.example.test', token: ' token'}), /must not contain whitespace/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://api.example.test/v1', token: 'test-token'}), /base URL is invalid/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://localhost', token: 'test-token'}), /base URL is invalid/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://192.0.2.10', token: 'test-token'}), /base URL is invalid/); }); test('NekiroApiClient rejects an empty JSON success body', async () => { diff --git a/src/api/nekiro.ts b/src/api/nekiro.ts index 55fdf58..952ece0 100644 --- a/src/api/nekiro.ts +++ b/src/api/nekiro.ts @@ -227,6 +227,8 @@ export interface InvocationEventV03 { workspaceId: string; targetAgentId: string; agentCardVersion: string; + agentReleaseId?: string; + agentCardDigest?: string; capability: string; chunkIndex?: number; chunkBytes?: number; @@ -243,6 +245,8 @@ export interface InvocationRecordV4 { workspaceId: string; targetAgentId: string; agentCardVersion: string; + agentReleaseId?: string; + agentCardDigest?: string; capability: string; status: InvocationEventStatus; latencyMs?: number; @@ -311,7 +315,15 @@ export class NekiroApiClient { } catch { throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is invalid.', 'CONFIGURATION_ERROR'); } - if (!['http:', 'https:'].includes(parsedBaseUrl.protocol) || parsedBaseUrl.username || parsedBaseUrl.password || parsedBaseUrl.search || parsedBaseUrl.hash) { + if (!['http:', 'https:'].includes(parsedBaseUrl.protocol) + || parsedBaseUrl.username + || parsedBaseUrl.password + || parsedBaseUrl.search + || parsedBaseUrl.hash + || parsedBaseUrl.pathname !== '/' + || parsedBaseUrl.hostname === 'localhost' + || parsedBaseUrl.hostname.includes('*') + || isIpHostname(parsedBaseUrl.hostname)) { throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is invalid.', 'CONFIGURATION_ERROR'); } this.baseUrl = options.baseUrl.replace(/\/+$/, ''); @@ -448,14 +460,14 @@ export class NekiroApiClient { } installAgent(workspaceId: string, request: InstallAgentRequest): Promise { - return this.request(this.workspaceInstallationPath(workspaceId), { + return this.request(this.workspaceInstallationPath(workspaceId), { method: 'POST', body: JSON.stringify({ agentId: readText(request.agentId, 'agentId'), versionConstraint: readText(request.versionConstraint, 'versionConstraint'), acceptedPermissions: request.acceptedPermissions, }), - }); + }).then((value) => validateInstallation(value, workspaceId)); } listInstallations(workspaceId: string, params: {limit: number; cursor?: string}): Promise { @@ -463,22 +475,22 @@ export class NekiroApiClient { throw new Error('installation limit must be an integer between 1 and 100'); } const query = this.queryString({limit: params.limit, cursor: params.cursor}); - return this.request(this.workspaceInstallationPath(workspaceId) + query); + return this.request(this.workspaceInstallationPath(workspaceId) + query).then((value) => validateInstallationList(value, workspaceId)); } getInstallation(workspaceId: string, installationId: string): Promise { - return this.request(this.installationPath(workspaceId, installationId)); + return this.request(this.installationPath(workspaceId, installationId)).then((value) => validateInstallation(value, workspaceId)); } updateInstallation(workspaceId: string, installationId: string, status: Exclude): Promise { - return this.request(this.installationPath(workspaceId, installationId), { + return this.request(this.installationPath(workspaceId, installationId), { method: 'PATCH', body: JSON.stringify({status}), - }); + }).then((value) => validateInstallation(value, workspaceId)); } uninstallAgent(workspaceId: string, installationId: string): Promise { - return this.request(this.installationPath(workspaceId, installationId), {method: 'DELETE'}); + return this.request(this.installationPath(workspaceId, installationId), {method: 'DELETE'}).then((value) => validateInstallation(value, workspaceId)); } invoke(workspaceId: string, request: InvocationRequestV4): Promise { @@ -880,7 +892,7 @@ function validateInvocationDetail(value: unknown, workspaceId: string): Invocati let previous: InvocationEventV03 | undefined; let expectedChunkIndex = 0; events.forEach((event, index) => { - if (event.sequence !== index || event.invocationId !== invocation.invocationId || event.rootTaskId !== invocation.rootTaskId || event.parentInvocationId !== invocation.parentInvocationId || event.traceId !== invocation.traceId || event.workspaceId !== invocation.workspaceId || event.targetAgentId !== invocation.targetAgentId || event.agentCardVersion !== invocation.agentCardVersion || event.capability !== invocation.capability || event.caller.type !== invocation.caller.type || event.caller.id !== invocation.caller.id) throw new Error('Invocation Detail event correlation is invalid'); + if (event.sequence !== index || event.invocationId !== invocation.invocationId || event.rootTaskId !== invocation.rootTaskId || event.parentInvocationId !== invocation.parentInvocationId || event.traceId !== invocation.traceId || event.workspaceId !== invocation.workspaceId || event.targetAgentId !== invocation.targetAgentId || event.agentCardVersion !== invocation.agentCardVersion || event.agentReleaseId !== invocation.agentReleaseId || event.agentCardDigest !== invocation.agentCardDigest || event.capability !== invocation.capability || event.caller.type !== invocation.caller.type || event.caller.id !== invocation.caller.id) throw new Error('Invocation Detail event correlation is invalid'); if (eventIDs.has(event.eventId)) throw new Error('Invocation Detail repeats an event'); eventIDs.add(event.eventId); if (!previous) { @@ -915,7 +927,7 @@ function validateTrace(value: unknown, workspaceId: string, traceId: string): Tr function validateInvocationRecord(value: unknown, workspaceId: string): InvocationRecordV4 { const record = requireRecord(value, 'Invocation Record'); - assertAllowedKeys(record, ['invocationId', 'rootTaskId', 'parentInvocationId', 'traceId', 'caller', 'workspaceId', 'targetAgentId', 'agentCardVersion', 'capability', 'status', 'latencyMs', 'errorCode', 'createdAt', 'updatedAt'], 'Invocation Record'); + assertAllowedKeys(record, ['invocationId', 'rootTaskId', 'parentInvocationId', 'traceId', 'caller', 'workspaceId', 'targetAgentId', 'agentCardVersion', 'agentReleaseId', 'agentCardDigest', 'capability', 'status', 'latencyMs', 'errorCode', 'createdAt', 'updatedAt'], 'Invocation Record'); const invocationId = record.invocationId; requireIdentifier(invocationId, 'invocationId'); requireIdentifier(record.rootTaskId, 'rootTaskId'); @@ -923,6 +935,7 @@ function validateInvocationRecord(value: unknown, workspaceId: string): Invocati if (record.workspaceId !== workspaceId) throw new Error('Invocation Record Workspace does not match the active Workspace'); requireIdentifier(record.targetAgentId, 'targetAgentId'); requireIdentifier(record.capability, 'capability'); if (typeof record.agentCardVersion !== 'string' || !isSemver(record.agentCardVersion)) throw new Error('agentCardVersion must be strict SemVer'); + validateReleaseProvenance(record.agentReleaseId, record.agentCardDigest, 'Invocation Record'); requireCaller(record.caller); requireEnum(record.status, ['pending', 'routing', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'], 'invocation status'); requireDate(record.createdAt, 'createdAt'); requireDate(record.updatedAt, 'updatedAt'); @@ -934,12 +947,13 @@ function validateInvocationRecord(value: unknown, workspaceId: string): Invocati function validateInvocationEvent(value: unknown, workspaceId: string): InvocationEventV03 { const record = requireRecord(value, 'Invocation Event'); - assertAllowedKeys(record, ['schemaVersion', 'eventId', 'sequence', 'occurredAt', 'type', 'status', 'invocationId', 'rootTaskId', 'parentInvocationId', 'traceId', 'caller', 'workspaceId', 'targetAgentId', 'agentCardVersion', 'capability', 'chunkIndex', 'chunkBytes', 'latencyMs', 'error'], 'Invocation Event'); + assertAllowedKeys(record, ['schemaVersion', 'eventId', 'sequence', 'occurredAt', 'type', 'status', 'invocationId', 'rootTaskId', 'parentInvocationId', 'traceId', 'caller', 'workspaceId', 'targetAgentId', 'agentCardVersion', 'agentReleaseId', 'agentCardDigest', 'capability', 'chunkIndex', 'chunkBytes', 'latencyMs', 'error'], 'Invocation Event'); if (record.schemaVersion !== '0.3' || typeof record.sequence !== 'number' || !Number.isInteger(record.sequence) || record.sequence < 0) throw new Error('Invocation Event schema or sequence is invalid'); requireIdentifier(record.eventId, 'eventId'); requireDate(record.occurredAt, 'occurredAt'); requireIdentifier(record.invocationId, 'invocationId'); requireIdentifier(record.rootTaskId, 'rootTaskId'); requireIdentifier(record.traceId, 'traceId'); if (record.workspaceId !== workspaceId) throw new Error('Invocation Event Workspace does not match the active Workspace'); requireCaller(record.caller); requireIdentifier(record.targetAgentId, 'targetAgentId'); if (typeof record.agentCardVersion !== 'string' || !isSemver(record.agentCardVersion)) throw new Error('agentCardVersion must be strict SemVer'); + validateReleaseProvenance(record.agentReleaseId, record.agentCardDigest, 'Invocation Event'); requireIdentifier(record.capability, 'capability'); const type = requireEnum(record.type, ['created', 'routing', 'started', 'stream', 'succeeded', 'failed', 'canceled', 'timed_out'], 'event type') as InvocationEventType; const status = requireEnum(record.status, ['pending', 'routing', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'], 'event status') as InvocationEventStatus; @@ -1204,6 +1218,63 @@ export function mapCatalogEntry(entry: CatalogEntry): Agent { }; } +function validateReleaseProvenance(releaseId: unknown, cardDigest: unknown, field: string): void { + if ((releaseId === undefined) !== (cardDigest === undefined)) throw new Error(`${field} Release provenance must contain both fields or neither`); + if (releaseId !== undefined) { + requireIdentifier(releaseId, field + ' agentReleaseId'); + requireDigest(cardDigest, field + ' agentCardDigest'); + } +} + +function validateInstallation(value: unknown, workspaceId: string): Installation { + const record = requireRecord(value, 'Installation'); + assertAllowedKeys(record, ['installationId', 'workspaceId', 'agentId', 'versionConstraint', 'installedVersion', 'installedReleaseId', 'acceptedPermissions', 'status', 'installedAt', 'updatedAt', 'uninstalledAt'], 'Installation'); + if (record.workspaceId !== workspaceId) throw new Error('Installation Workspace does not match the request'); + const installationID = readIdentifier(record.installationId, 'installationId'); + const agentID = readIdentifier(record.agentId, 'agentId'); + const versionConstraint = readText(record.versionConstraint, 'versionConstraint'); + const installedVersion = requireSemver(record.installedVersion, 'installedVersion'); + if (!satisfiesSemverRange(installedVersion, versionConstraint)) throw new Error('installedVersion does not satisfy versionConstraint'); + const acceptedPermissions = readStringArray(record.acceptedPermissions, 'acceptedPermissions'); + if ([...acceptedPermissions].sort().join('\u0000') !== acceptedPermissions.join('\u0000')) throw new Error('acceptedPermissions must be sorted'); + const status = requireEnum(record.status, ['enabled', 'disabled', 'uninstalled'], 'Installation status') as InstallationStatus; + const installedAt = requireDateValue(record.installedAt, 'installedAt'); + const updatedAt = requireDateValue(record.updatedAt, 'updatedAt'); + if (Date.parse(installedAt) > Date.parse(updatedAt)) throw new Error('Installation updatedAt must not precede installedAt'); + const result: Installation = { + installationId: installationID, + workspaceId, + agentId: agentID, + versionConstraint, + installedVersion, + acceptedPermissions, + status, + installedAt, + updatedAt, + }; + if ('installedReleaseId' in record) result.installedReleaseId = readIdentifier(record.installedReleaseId, 'installedReleaseId'); + if (status === 'uninstalled') { + if (!('uninstalledAt' in record)) throw new Error('uninstalled Installation requires uninstalledAt'); + const uninstalledAt = requireDateValue(record.uninstalledAt, 'uninstalledAt'); + if (Date.parse(uninstalledAt) !== Date.parse(updatedAt)) throw new Error('uninstalledAt must equal updatedAt'); + result.uninstalledAt = uninstalledAt; + } else if ('uninstalledAt' in record) { + throw new Error('active Installation must not contain uninstalledAt'); + } + return result; +} + +function validateInstallationList(value: unknown, workspaceId: string): InstallationList { + const record = requireRecord(value, 'Installation list'); + assertAllowedKeys(record, ['items', 'nextCursor'], 'Installation list'); + if (!Array.isArray(record.items)) throw new Error('Installation list items must be an array'); + const result: InstallationList = { + items: record.items.map((item) => validateInstallation(item, workspaceId)), + }; + if ('nextCursor' in record) result.nextCursor = readText(record.nextCursor, 'nextCursor'); + return result; +} + export function toPlatformErrorView(error: unknown, _fallbackMessage: string): PlatformErrorView { if (error instanceof NekiroApiError) { return error.toView(); @@ -1215,6 +1286,13 @@ export function toPlatformErrorView(error: unknown, _fallbackMessage: string): P }; } +export function validateTrustedInstallation(value: Installation, release: AgentRelease, agentId: string): Installation { + if (value.agentId !== agentId || value.installedVersion !== release.agentCardVersion || value.installedReleaseId !== release.releaseId || value.status !== 'enabled') { + throw new NekiroApiError(200, 'NeKiro Installation did not preserve the preflight Release identity.', 'INVALID_RESPONSE'); + } + return value; +} + function parseCapabilities(value: string): Record[] { let parsed: unknown; try { @@ -1246,6 +1324,91 @@ function readIdentifier(value: unknown, field: string): string { return text; } +function isIpHostname(hostname: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':'); +} + +interface SemverParts { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +function satisfiesSemverRange(version: string, range: string): boolean { + const parsedVersion = parseSemver(version); + if (!parsedVersion) return false; + return range.split('||').some((branch) => satisfiesSemverBranch(parsedVersion, branch)); +} + +function satisfiesSemverBranch(version: SemverParts, branch: string): boolean { + const tokens = branch.trim().split(/[\s,]+/).filter(Boolean); + if (tokens.length === 0) return false; + return tokens.every((token) => satisfiesSemverToken(version, token)); +} + +function satisfiesSemverToken(version: SemverParts, token: string): boolean { + if (token === '*' || token.toLowerCase() === 'x') return true; + const wildcard = /^(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(token); + if (wildcard) { + if (wildcard[1] === 'x' || wildcard[1] === 'X' || wildcard[1] === '*') return true; + if (version.major !== Number(wildcard[1])) return false; + if (wildcard[2] === undefined || ['x', 'X', '*'].includes(wildcard[2])) return true; + if (version.minor !== Number(wildcard[2])) return false; + return wildcard[3] === undefined || ['x', 'X', '*'].includes(wildcard[3]) || version.patch === Number(wildcard[3]); + } + const operatorMatch = /^(\^|~|>=|<=|>|<|=)?(.+)$/.exec(token); + if (!operatorMatch) return false; + const operator = operatorMatch[1] ?? '='; + const base = parseSemver(operatorMatch[2]); + if (!base) return false; + const comparison = compareSemver(version, base); + if (operator === '=') return comparison === 0; + if (operator === '>') return comparison > 0; + if (operator === '>=') return comparison >= 0; + if (operator === '<') return comparison < 0; + if (operator === '<=') return comparison <= 0; + if (operator === '^') { + const upper = base.major > 0 + ? {major: base.major + 1, minor: 0, patch: 0, prerelease: []} + : base.minor > 0 + ? {major: 0, minor: base.minor + 1, patch: 0, prerelease: []} + : {major: 0, minor: 0, patch: base.patch + 1, prerelease: []}; + return compareSemver(version, base) >= 0 && compareSemver(version, upper) < 0; + } + const upper = {major: base.major, minor: base.minor + 1, patch: 0, prerelease: []}; + return compareSemver(version, base) >= 0 && compareSemver(version, upper) < 0; +} + +function parseSemver(value: string): SemverParts | undefined { + if (!isSemver(value)) return undefined; + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value); + if (!match) return undefined; + return {major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4]?.split('.') ?? []}; +} + +function compareSemver(left: SemverParts, right: SemverParts): number { + for (const field of ['major', 'minor', 'patch'] as const) { + if (left[field] !== right[field]) return left[field] > right[field] ? 1 : -1; + } + if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0; + if (left.prerelease.length === 0) return 1; + if (right.prerelease.length === 0) return -1; + for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index += 1) { + const leftPart = left.prerelease[index]; + const rightPart = right.prerelease[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + const leftNumber = /^\d+$/.test(leftPart); + const rightNumber = /^\d+$/.test(rightPart); + if (leftNumber && rightNumber) return Number(leftPart) > Number(rightPart) ? 1 : -1; + if (leftNumber !== rightNumber) return leftNumber ? -1 : 1; + return leftPart > rightPart ? 1 : -1; + } + return 0; +} + function readStringArray(value: unknown, field: string): string[] { if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim() !== '' && item === item.trim())) { throw new Error(field + ' must be an array of non-empty strings'); diff --git a/src/components/InstallationsTab.tsx b/src/components/InstallationsTab.tsx index 09b728d..17e158c 100644 --- a/src/components/InstallationsTab.tsx +++ b/src/components/InstallationsTab.tsx @@ -1,7 +1,8 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {AlertTriangle, Database, Loader2, RefreshCw, ShieldCheck, Trash2} from 'lucide-react'; -import {toPlatformErrorView} from '../api/nekiro'; +import {NekiroApiError, toPlatformErrorView, type AgentRelease, type NekiroApiClient} from '../api/nekiro'; +import {agentKey, isCurrentRequest, matchesPublishedRelease, nextRequestGeneration} from '../consolePolicy'; import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from '../types'; interface InstallationsTabProps { @@ -11,10 +12,10 @@ interface InstallationsTabProps { loading: boolean; error: PlatformErrorView | null; searchQuery: string; - preselectedAgentId?: string; - onInstallAgent: (agent: Agent, versionConstraint: string, acceptedPermissions: string[]) => Promise; + client: NekiroApiClient; + onInstallAgent: (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => Promise; onUpdateInstallation: (installation: Installation, status: Exclude) => Promise; - onUninstall: (installation: Installation) => Promise; + onUninstall: (installation: Installation) => Promise; onRefresh: () => void; } @@ -25,30 +26,41 @@ export default function InstallationsTab({ loading, error, searchQuery, - preselectedAgentId, + client, onInstallAgent, onUpdateInstallation, onUninstall, onRefresh, }: InstallationsTabProps) { const publishedAgents = useMemo(() => agents.filter((agent) => agent.status === 'published'), [agents]); - const [selectedAgentId, setSelectedAgentId] = useState(''); + const [selectedAgentKey, setSelectedAgentKey] = useState(''); const [versionConstraint, setVersionConstraint] = useState(''); const [acceptedPermissions, setAcceptedPermissions] = useState([]); + const [releaseId, setReleaseId] = useState(''); + const [preflightRelease, setPreflightRelease] = useState(null); + const [preflightLoading, setPreflightLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [localError, setLocalError] = useState(null); const [confirmUninstallId, setConfirmUninstallId] = useState(null); + const [busyLifecycle, setBusyLifecycle] = useState(false); + const preflightGeneration = useRef(0); + + const invalidatePreflight = () => { + preflightGeneration.current = nextRequestGeneration(preflightGeneration.current); + setPreflightRelease(null); + }; useEffect(() => { - const next = publishedAgents.find((agent) => agent.id === preselectedAgentId) ?? publishedAgents[0]; - if (next && selectedAgentId === '') { - setSelectedAgentId(next.id); + if (!selectedAgentKey && publishedAgents[0]) { + const next = publishedAgents[0]; + invalidatePreflight(); + setSelectedAgentKey(agentKey(next)); setVersionConstraint(next.version); setAcceptedPermissions(next.permissions.map((permission) => permission.id).sort()); } - }, [preselectedAgentId, publishedAgents, selectedAgentId]); + }, [publishedAgents, selectedAgentKey]); - const selectedAgent = publishedAgents.find((agent) => agent.id === selectedAgentId); + const selectedAgent = publishedAgents.find((agent) => agentKey(agent) === selectedAgentKey); const filteredInstallations = installations.filter((installation) => { const query = searchQuery.trim().toLowerCase(); if (!query) return true; @@ -63,20 +75,48 @@ export default function InstallationsTab({ ].join(' ').toLowerCase().includes(query); }); - const handleSelectAgent = (agentId: string) => { - const agent = publishedAgents.find((item) => item.id === agentId); - setSelectedAgentId(agentId); + const handleSelectAgent = (selectedKey: string) => { + const agent = publishedAgents.find((item) => agentKey(item) === selectedKey); + invalidatePreflight(); + setSelectedAgentKey(selectedKey); setVersionConstraint(agent?.version ?? ''); setAcceptedPermissions(agent?.permissions.map((permission) => permission.id).sort() ?? []); + setReleaseId(''); + setPreflightRelease(null); + setLocalError(null); + }; + + const handlePreflight = async () => { + if (!selectedAgent) return; + const generation = nextRequestGeneration(preflightGeneration.current); + preflightGeneration.current = generation; + const requestedAgentKey = selectedAgentKey; + const requestedReleaseId = releaseId; + setPreflightLoading(true); + setLocalError(null); + try { + const value = await client.getAgentRelease(requestedReleaseId); + if (!isCurrentRequest(generation, preflightGeneration.current)) return; + if (!matchesPublishedRelease(value, selectedAgent)) throw new NekiroApiError(200, 'The selected Release is not a published match for the selected Agent Card.', 'INVALID_RESPONSE'); + if (requestedAgentKey !== selectedAgentKey || requestedReleaseId !== releaseId) return; + setPreflightRelease(value); + setVersionConstraint(value.agentCardVersion); + } catch (value) { + if (!isCurrentRequest(generation, preflightGeneration.current)) return; + setPreflightRelease(null); + setLocalError(toPlatformErrorView(value, 'Unable to preflight the trusted Release.')); + } finally { + if (isCurrentRequest(generation, preflightGeneration.current)) setPreflightLoading(false); + } }; const handleInstall = async (event: React.FormEvent) => { event.preventDefault(); - if (!selectedAgent) return; + if (!selectedAgent || !preflightRelease) return; setSubmitting(true); setLocalError(null); try { - await onInstallAgent(selectedAgent, versionConstraint, acceptedPermissions); + await onInstallAgent(selectedAgent, preflightRelease, acceptedPermissions); } catch (installError) { setLocalError(toPlatformErrorView(installError, 'Unable to install Agent.')); } finally { @@ -97,10 +137,10 @@ export default function InstallationsTab({
Installations

Workspace Agent Pins

- Install published Catalog versions into the current Workspace with explicit acceptedPermissions, then inspect and manage Installation v2 lifecycle facts. + Preflight an immutable published Release before installing its exact Card version into the current Workspace.

- @@ -119,22 +159,29 @@ export default function InstallationsTab({
-
Install published Agent
-
acceptedPermissions is always submitted, including explicit empty arrays.
+
Install trusted Release
+
The Release ID is an explicit provider handoff; Catalog publication alone is not trust.
- + + +
+ +
{ invalidatePreflight(); setReleaseId(event.target.value); setLocalError(null); }} disabled={!workspace || preflightLoading || busyLifecycle} placeholder="release-id" className="flex-1 bg-brand-lowest border border-brand-outline-variant rounded px-3 py-2 text-brand-on-surface outline-none disabled:opacity-50" />
+
+ + {preflightRelease &&
Published Release preflight passed
}
Declared permissions
@@ -145,7 +192,7 @@ export default function InstallationsTab({ )} {selectedAgent?.permissions.map((permission) => (