diff --git a/.gitignore b/.gitignore index 836955d..aa5cbc0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ yarn-error.log* # Coverage coverage/ .nyc_output/ +playwright-report/ +test-results/ # Temporary files tmp/ diff --git a/AGENTS.md b/AGENTS.md index 909b250..c472b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ This repository publishes the open-source server, console, integration catalog, ## Sources of Truth - `integrations/catalog.json` owns provider IDs, official hook contracts, blocking semantics, release variants, and adapter delivery state. +- The server exports that catalog as `INTEGRATION_TYPES`; registration requires an explicit member before any database access. - Official provider documentation owns every external hook API fact. - The standalone SDK repositories own released package behavior. Mirror a reviewed standalone commit here without semantic drift. - Backend schemas own persisted data contracts. Shared types and Console consumers must follow those schemas. @@ -52,6 +53,24 @@ This repository publishes the open-source server, console, integration catalog, ## Verification +Server: + +```powershell +cd packages/server +npm test +npm run typecheck +npm audit --omit=dev --audit-level=high +``` + +Console: + +```powershell +cd packages/console +npm run typecheck +npm run build +npm run test:e2e +``` + Node SDK: ```powershell diff --git a/README.md b/README.md index 7a2d171..e533f80 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ Register a new agent. Required role: **integration_engineer**. ```json { "agent_id": "my-agent-v1", + "integration_type": "codex", "display_name": "Customer Support Bot", "responsible_entity": "engineering-team@example.com", "keys": [ @@ -318,6 +319,8 @@ Retrieve a specific agent and its keys. Required role: **readonly_investigator** Update agent metadata. Required role: **integration_engineer**. +**Request body:** `{ "integration_type": "codex" }` + --- #### `DELETE /v1/agents/:id` @@ -566,6 +569,7 @@ client.setToken(token); // 3. Register the agent with its public key await client.registerAgent({ agent_id: 'order-processor-v1', + integration_type: 'sdk', display_name: 'Order Processor', keys: [{ kid: 'order-processor-v1-key-v1', public_key: client.getPublicKey(), algorithm: 'ed25519' }], }); @@ -627,6 +631,7 @@ client = ElydoraClient( from elydora.crypto import derive_public_key client.register_agent({ "agent_id": "order-processor-v1", + "integration_type": "sdk", "display_name": "Order Processor", "keys": [{"kid": "order-processor-v1-key-v1", "public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]), diff --git a/docker-compose.yml b/docker-compose.yml index 6bdc405..cae06a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,7 +121,14 @@ services: org_id TEXT NOT NULL REFERENCES organizations (org_id), display_name TEXT NOT NULL, responsible_entity TEXT NOT NULL, - integration_type TEXT NOT NULL DEFAULT 'sdk', + integration_type TEXT NOT NULL + CONSTRAINT agents_integration_type_check + CHECK (integration_type IN ( + 'augment', 'claudecode', 'cline', 'codex', 'copilot', + 'cursor', 'droid', 'gemini', 'grok', 'kimi', 'kirocli', + 'kiroide', 'letta', 'opencode', 'qwen', 'enterprise', + 'gui', 'sdk', 'other' + )), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'frozen', 'revoked')), created_at BIGINT NOT NULL, @@ -130,6 +137,17 @@ services: CREATE INDEX IF NOT EXISTS idx_agents_org_id ON agents (org_id); + ALTER TABLE agents ALTER COLUMN integration_type DROP DEFAULT; + ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_integration_type_check; + ALTER TABLE agents + ADD CONSTRAINT agents_integration_type_check + CHECK (integration_type IN ( + 'augment', 'claudecode', 'cline', 'codex', 'copilot', + 'cursor', 'droid', 'gemini', 'grok', 'kimi', 'kirocli', + 'kiroide', 'letta', 'opencode', 'qwen', 'enterprise', + 'gui', 'sdk', 'other' + )); + CREATE TABLE IF NOT EXISTS agent_keys ( kid TEXT NOT NULL PRIMARY KEY, agent_id TEXT NOT NULL REFERENCES agents (agent_id), @@ -216,6 +234,10 @@ services: INSERT INTO schema_versions (version, applied_at, description) VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema') ON CONFLICT DO NOTHING; + + INSERT INTO schema_versions (version, applied_at, description) + VALUES (2, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Require supported agent integration type') + ON CONFLICT DO NOTHING; " restart: on-failure diff --git a/integrations/catalog.json b/integrations/catalog.json index a329ace..da8df72 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -495,15 +495,15 @@ "name": "GUI Agent", "description": "Desktop or browser automation agent" }, - { - "id": "other", - "name": "Other", - "description": "Custom integration outside the provider catalog" - }, { "id": "sdk", "name": "SDK", "description": "Direct Elydora SDK integration" + }, + { + "id": "other", + "name": "Other", + "description": "Custom integration outside the provider catalog" } ] } diff --git a/package.json b/package.json index 262081d..edbc6b7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "license": "Apache-2.0", "scripts": { "validate:integrations": "node --test test/integration-catalog.test.mjs", + "test:server": "cd packages/server && npm test", + "test:console:e2e": "cd packages/console && npm run test:e2e", "build:server": "cd packages/server && npm run build", "build:console": "cd packages/console && npm run build", "build": "npm run build:server && npm run build:console", diff --git a/packages/console/e2e/agent-registration-contract.spec.ts b/packages/console/e2e/agent-registration-contract.spec.ts new file mode 100644 index 0000000..31c5925 --- /dev/null +++ b/packages/console/e2e/agent-registration-contract.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from '@playwright/test'; +import { buildInstallInstructions, SDK_LANGUAGES } from '@/features/agent-registration/install-instructions'; +import { + INTEGRATION_CATALOG, + INTEGRATION_TYPES, +} from '@/features/agent-registration/integrations'; +import { + assertRegistrationResponse, + RegistrationContractError, +} from '@/features/agent-registration/registration-contract'; + +const EXPECTED_INTEGRATIONS = [ + 'augment', + 'claudecode', + 'cline', + 'codex', + 'copilot', + 'cursor', + 'droid', + 'gemini', + 'grok', + 'kimi', + 'kirocli', + 'kiroide', + 'letta', + 'opencode', + 'qwen', + 'enterprise', + 'gui', + 'sdk', + 'other', +] as const; + +const credentials = { + agentId: 'agent-contract', + kid: 'agent-contract-key-1', + publicKey: 'public-key', + privateKey: 'private-key', + orgId: 'org-contract', +}; + +test('keeps the complete integration catalog in canonical order', () => { + expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS); + expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS); + expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19); + expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')).toHaveLength(15); + expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'sdk')).toHaveLength(4); +}); + +test('generates every adapter command with the verified SDK flag contract', () => { + for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')) { + for (const language of SDK_LANGUAGES) { + const instructions = buildInstallInstructions(language, { + integration, + credentials, + token: 'token-contract', + baseUrl: 'https://api.example.com', + }); + + expect(instructions.setup).toContain(`--agent ${integration.id}`); + expect(instructions.setup).toContain('"private-key"'); + expect(instructions.setup).toContain('"token-contract"'); + expect(instructions.setup).toContain('"https://api.example.com"'); + + if (language === 'go') { + expect(instructions.setup).toContain('--org-id "org-contract"'); + expect(instructions.setup).toContain('--agent-id "agent-contract"'); + expect(instructions.setup).toContain('--private-key "private-key"'); + expect(instructions.setup).toContain('--base-url "https://api.example.com"'); + } else { + expect(instructions.setup).toContain('--org_id "org-contract"'); + expect(instructions.setup).toContain('--agent_id "agent-contract"'); + expect(instructions.setup).toContain('--private_key "private-key"'); + expect(instructions.setup).toContain('--base_url "https://api.example.com"'); + } + } + } +}); + +test('generates direct SDK setup and operation code for each language', () => { + const integration = INTEGRATION_CATALOG.find(({ id }) => id === 'sdk'); + expect(integration).toBeDefined(); + + for (const language of SDK_LANGUAGES) { + const instructions = buildInstallInstructions(language, { + integration: integration!, + credentials, + token: 'token-contract', + baseUrl: 'https://api.example.com', + }); + expect(instructions.setup).toContain('agent-contract'); + expect(instructions.setup).toContain('token-contract'); + expect(instructions.usage).toContain('ai.tool_use'); + expect(instructions.verify).toBeUndefined(); + } +}); + +test('classifies malformed successful responses as blocking contract failures', () => { + expect(() => assertRegistrationResponse( + null as never, + credentials, + 'grok', + )).toThrow(RegistrationContractError); +}); diff --git a/packages/console/e2e/agent-registration.spec.ts b/packages/console/e2e/agent-registration.spec.ts new file mode 100644 index 0000000..8f14d5f --- /dev/null +++ b/packages/console/e2e/agent-registration.spec.ts @@ -0,0 +1,194 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const API_BASE_URL = 'http://localhost:8787'; +const ORG_ID = 'org-e2e'; +const TOKEN = 'ely_test_registration_token'; + +interface ObservedRequest { + readonly method: string; + readonly path: string; + readonly body: unknown; +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); +} + +async function waitForRenderedDialog(page: Page) { + await page.getByRole('dialog').evaluate(async (dialog) => { + await Promise.all(dialog.getAnimations().map((animation) => animation.finished)); + }); +} + +async function mockBackend( + page: Page, + observed: ObservedRequest[], + unexpected: string[], + registrationIntegrationOverride?: string, +) { + await page.route(`${API_BASE_URL}/**`, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const method = request.method(); + const body = request.postDataJSON() as unknown; + observed.push({ method, path, body }); + + if (method === 'OPTIONS') { + await route.fulfill({ status: 204 }); + return; + } + + if (method === 'GET' && path === '/api/auth/get-session') { + await json(route, { + user: { + id: 'user-e2e', + name: 'E2E Owner', + email: 'owner@example.com', + emailVerified: true, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + onboarding_completed: true, + org_id: ORG_ID, + role: 'org_owner', + }, + session: { + id: 'session-e2e', + userId: 'user-e2e', + token: 'session-token', + activeOrganizationId: ORG_ID, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + }); + return; + } + + if (method === 'GET' && path === '/v1/agents') { + await json(route, { agents: [] }); + return; + } + + if (method === 'POST' && path === '/v1/agents/register') { + const registration = body as { + agent_id: string; + display_name: string; + responsible_entity?: string; + integration_type: string; + keys: Array<{ kid: string; public_key: string; algorithm: string }>; + }; + await json(route, { + agent: { + agent_id: registration.agent_id, + org_id: ORG_ID, + display_name: registration.display_name, + responsible_entity: registration.responsible_entity ?? '', + integration_type: registrationIntegrationOverride ?? registration.integration_type, + status: 'active', + created_at: Date.now(), + updated_at: Date.now(), + }, + keys: registration.keys.map((key) => ({ + ...key, + agent_id: registration.agent_id, + status: 'active', + created_at: Date.now(), + retired_at: null, + })), + }); + return; + } + + if (method === 'POST' && path === '/v1/auth/token') { + await json(route, { token: TOKEN, expires_at: Date.now() + 86_400_000 }); + return; + } + + unexpected.push(`${method} ${path}`); + await json(route, { error: { code: 'UNEXPECTED_E2E_REQUEST' } }, 501); + }); +} + +test('registers Grok atomically and renders verified adapter commands', async ({ page }) => { + const observed: ObservedRequest[] = []; + const unexpected: string[] = []; + const runtimeErrors: string[] = []; + page.on('pageerror', (error) => runtimeErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') runtimeErrors.push(message.text()); + }); + await mockBackend(page, observed, unexpected); + + await page.goto('/agents'); + await page.getByRole('button', { name: 'Register Agent' }).click(); + await waitForRenderedDialog(page); + await expect(page.getByRole('button', { name: 'Augment Code' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Kimi CLI' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Qwen Code' })).toBeVisible(); + await page.screenshot({ path: 'test-results/agent-registration-catalog.png', fullPage: true }); + await page.getByRole('button', { name: 'Grok Build' }).click(); + await page.getByLabel('Display Name').fill('Production Grok'); + await page.getByLabel('Responsible Entity (optional)').fill('Platform Security'); + await page.locator('form').getByRole('button', { name: 'Register Agent' }).click(); + + await expect(page.getByText('Private Key — Save This Now')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Close' })).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(page.getByText('Private Key — Save This Now')).toBeVisible(); + await page.screenshot({ path: 'test-results/agent-registration-credentials.png', fullPage: true }); + await page.getByLabel('Token Expiration').selectOption('custom'); + await page.getByLabel('days').fill('366'); + await page.getByRole('button', { name: 'Issue API Token' }).click(); + await expect(page.getByText('Enter a whole number from 1 to 365.')).toBeVisible(); + expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(0); + await page.getByLabel('Token Expiration').selectOption('24hours'); + await page.getByRole('button', { name: 'Issue API Token' }).click(); + await expect(page.getByText(TOKEN)).toBeVisible(); + await page.getByRole('button', { name: 'Continue' }).click(); + + const setup = page.locator('pre'); + await expect(setup).toContainText('npx @elydora/sdk install --agent grok'); + await expect(setup).toContainText(`--org_id "${ORG_ID}"`); + await expect(setup).toContainText(`--token "${TOKEN}"`); + + await page.getByRole('tab', { name: 'go' }).click(); + await expect(setup).toContainText('elydora install --agent grok'); + await expect(setup).toContainText(`--org-id "${ORG_ID}"`); + await expect(setup).toContainText('--agent-id "agent-'); + + await page.screenshot({ path: 'test-results/agent-registration-grok.png', fullPage: true }); + + const registrationRequests = observed.filter(({ path }) => path === '/v1/agents/register'); + expect(registrationRequests).toHaveLength(1); + expect(registrationRequests[0]?.method).toBe('POST'); + expect(registrationRequests[0]?.body).toMatchObject({ + display_name: 'Production Grok', + responsible_entity: 'Platform Security', + integration_type: 'grok', + }); + expect(observed.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1); + expect(unexpected).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); + +test('blocks the success state when the backend returns a different integration', async ({ page }) => { + const observed: ObservedRequest[] = []; + const unexpected: string[] = []; + await mockBackend(page, observed, unexpected, 'codex'); + + await page.goto('/agents'); + await page.getByRole('button', { name: 'Register Agent' }).click(); + await page.getByRole('button', { name: 'Grok Build' }).click(); + await page.getByLabel('Display Name').fill('Mismatched Grok'); + await page.locator('form').getByRole('button', { name: 'Register Agent' }).click(); + + await expect(page.locator('form [role="alert"]')).toContainText('different integration type'); + await expect(page.getByText('Private Key — Save This Now')).toHaveCount(0); + await expect(page.locator('form').getByRole('button', { name: 'Register Agent' })).toBeDisabled(); + expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1); + expect(observed.filter(({ method }) => method === 'PATCH')).toHaveLength(0); + expect(unexpected).toEqual([]); +}); diff --git a/packages/console/next.config.ts b/packages/console/next.config.ts index 225e495..fb02d97 100644 --- a/packages/console/next.config.ts +++ b/packages/console/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; +import path from "node:path"; + +const monorepoRoot = path.join(__dirname, "../.."); const nextConfig: NextConfig = { output: 'standalone', + outputFileTracingRoot: monorepoRoot, + turbopack: { + root: monorepoRoot, + }, }; export default nextConfig; diff --git a/packages/console/package-lock.json b/packages/console/package-lock.json index c983446..7bf5a60 100644 --- a/packages/console/package-lock.json +++ b/packages/console/package-lock.json @@ -8,23 +8,27 @@ "name": "elydora-console", "version": "0.1.0", "dependencies": { - "better-auth": "^1.5.5", + "better-auth": "^1.6.23", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", - "next": "^16.1.6", + "next": "^16.2.10", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "swr": "^2.3.0" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.0.0", "@types/node": "25.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "postcss": "^8.0.0", + "postcss": "^8.5.20", "tailwindcss": "^4.0.0", "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@alloc/quick-lru": { @@ -50,9 +54,9 @@ } }, "node_modules/@better-auth/core": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.6.tgz", - "integrity": "sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.23.tgz", + "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", "license": "MIT", "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", @@ -60,30 +64,33 @@ "zod": "^4.3.6" }, "peerDependencies": { - "@better-auth/utils": "0.3.1", - "@better-fetch/fetch": "1.1.21", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", - "better-call": "1.3.2", + "better-call": "1.3.7", "jose": "^6.1.0", - "kysely": "^0.28.5", + "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { "optional": true + }, + "@opentelemetry/api": { + "optional": true } } }, "node_modules/@better-auth/drizzle-adapter": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.6.tgz", - "integrity": "sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.23.tgz", + "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "1.5.6", - "@better-auth/utils": "^0.3.0", - "drizzle-orm": ">=0.41.0" + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" }, "peerDependenciesMeta": { "drizzle-orm": { @@ -92,14 +99,14 @@ } }, "node_modules/@better-auth/kysely-adapter": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.6.tgz", - "integrity": "sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.23.tgz", + "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "1.5.6", - "@better-auth/utils": "^0.3.0", - "kysely": "^0.27.0 || ^0.28.0" + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" }, "peerDependenciesMeta": { "kysely": { @@ -108,23 +115,23 @@ } }, "node_modules/@better-auth/memory-adapter": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.6.tgz", - "integrity": "sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.23.tgz", + "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "1.5.6", - "@better-auth/utils": "^0.3.0" + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2" } }, "node_modules/@better-auth/mongo-adapter": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.6.tgz", - "integrity": "sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.23.tgz", + "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "1.5.6", - "@better-auth/utils": "^0.3.0", + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { @@ -134,13 +141,13 @@ } }, "node_modules/@better-auth/prisma-adapter": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.6.tgz", - "integrity": "sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.23.tgz", + "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "1.5.6", - "@better-auth/utils": "^0.3.0", + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, @@ -154,28 +161,30 @@ } }, "node_modules/@better-auth/telemetry": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.6.tgz", - "integrity": "sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.23.tgz", + "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", "license": "MIT", - "dependencies": { - "@better-auth/utils": "0.3.1", - "@better-fetch/fetch": "1.1.21" - }, "peerDependencies": { - "@better-auth/core": "1.5.6" + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" } }, "node_modules/@better-auth/utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz", - "integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==", - "license": "MIT" + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } }, "node_modules/@better-fetch/fetch": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" }, "node_modules/@emnapi/runtime": { "version": "1.8.1", @@ -704,15 +713,15 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", + "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", + "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", "cpu": [ "arm64" ], @@ -726,9 +735,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", + "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", "cpu": [ "x64" ], @@ -742,12 +751,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", + "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -758,12 +770,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", + "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -774,12 +789,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", + "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -790,12 +808,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", + "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -806,9 +827,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", + "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", "cpu": [ "arm64" ], @@ -822,9 +843,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", + "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", "cpu": [ "x64" ], @@ -861,25 +882,31 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1209,26 +1236,26 @@ } }, "node_modules/better-auth": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.6.tgz", - "integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==", + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz", + "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", "license": "MIT", "dependencies": { - "@better-auth/core": "1.5.6", - "@better-auth/drizzle-adapter": "1.5.6", - "@better-auth/kysely-adapter": "1.5.6", - "@better-auth/memory-adapter": "1.5.6", - "@better-auth/mongo-adapter": "1.5.6", - "@better-auth/prisma-adapter": "1.5.6", - "@better-auth/telemetry": "1.5.6", - "@better-auth/utils": "0.3.1", - "@better-fetch/fetch": "1.1.21", + "@better-auth/core": "1.6.23", + "@better-auth/drizzle-adapter": "1.6.23", + "@better-auth/kysely-adapter": "1.6.23", + "@better-auth/memory-adapter": "1.6.23", + "@better-auth/mongo-adapter": "1.6.23", + "@better-auth/prisma-adapter": "1.6.23", + "@better-auth/telemetry": "1.6.23", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", - "better-call": "1.3.2", + "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", - "kysely": "^0.28.12", + "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, @@ -1240,7 +1267,7 @@ "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", - "drizzle-orm": ">=0.41.0", + "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", @@ -1314,12 +1341,12 @@ } }, "node_modules/better-call": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz", - "integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", "license": "MIT", "dependencies": { - "@better-auth/utils": "^0.3.1", + "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" @@ -1367,9 +1394,9 @@ "license": "MIT" }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, "node_modules/dequal": { @@ -1405,6 +1432,20 @@ "node": ">=10.13.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1472,21 +1513,21 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/kysely": { - "version": "0.28.14", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz", - "integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==", + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/lightningcss": { @@ -1761,9 +1802,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -1779,9 +1820,9 @@ } }, "node_modules/nanostores": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", - "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.0.tgz", + "integrity": "sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA==", "funding": [ { "type": "github", @@ -1794,14 +1835,14 @@ } }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", + "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.10", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -1813,15 +1854,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.10", + "@next/swc-darwin-x64": "16.2.10", + "@next/swc-linux-arm64-gnu": "16.2.10", + "@next/swc-linux-arm64-musl": "16.2.10", + "@next/swc-linux-x64-gnu": "16.2.10", + "@next/swc-linux-x64-musl": "16.2.10", + "@next/swc-win32-arm64-msvc": "16.2.10", + "@next/swc-win32-x64-msvc": "16.2.10", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -1846,45 +1887,48 @@ } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "funding": [ { "type": "opencollective", @@ -1901,7 +1945,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1983,9 +2027,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", - "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", "license": "MIT" }, "node_modules/sharp": { @@ -2145,9 +2189,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/packages/console/package.json b/packages/console/package.json index a2fbd4c..a5081e5 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -2,29 +2,37 @@ "name": "elydora-console", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.9.0" + }, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" }, "dependencies": { - "better-auth": "^1.5.5", + "better-auth": "^1.6.23", "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", - "next": "^16.1.6", + "next": "^16.2.10", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "swr": "^2.3.0" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.0.0", "@types/node": "25.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "postcss": "^8.0.0", + "postcss": "^8.5.20", "tailwindcss": "^4.0.0", "typescript": "^5.7.0" + }, + "overrides": { + "postcss": "$postcss" } } diff --git a/packages/console/playwright.config.ts b/packages/console/playwright.config.ts new file mode 100644 index 0000000..0b7b00c --- /dev/null +++ b/packages/console/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: 'http://127.0.0.1:3100', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev -- --hostname 127.0.0.1 --port 3100', + url: 'http://127.0.0.1:3100/agents', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/packages/console/src/app/agents/page.tsx b/packages/console/src/app/agents/page.tsx index 6e378c2..878d2c3 100644 --- a/packages/console/src/app/agents/page.tsx +++ b/packages/console/src/app/agents/page.tsx @@ -40,6 +40,7 @@ function AgentsContent() { const router = useRouter(); const [search, setSearch] = useState(''); const [showRegister, setShowRegister] = useState(false); + const [registrationDismissible, setRegistrationDismissible] = useState(true); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); @@ -132,11 +133,27 @@ function AgentsContent() { [router], ); - const handleRegisterSuccess = useCallback(() => { - setShowRegister(false); - mutate(); + const refreshAgents = useCallback(() => { + void mutate().catch((error: unknown) => { + console.error('Failed to refresh agents after registration.', error); + }); }, [mutate]); + const handleRegisterSuccess = useCallback(() => { + setRegistrationDismissible(true); + setShowRegister(false); + refreshAgents(); + }, [refreshAgents]); + + const openRegistration = useCallback(() => { + setRegistrationDismissible(true); + setShowRegister(true); + }, []); + + const closeRegistration = useCallback(() => { + if (registrationDismissible) setShowRegister(false); + }, [registrationDismissible]); + const handleDelete = useCallback(async () => { if (!deleteTarget) return; setDeleting(true); @@ -162,7 +179,7 @@ function AgentsContent() { { label: t('agents.title') }, ]} actions={ - } @@ -194,13 +211,15 @@ function AgentsContent() { setShowRegister(false)} + onClose={closeRegistration} title={t('agents.registerAgent')} width="max-w-2xl" + dismissible={registrationDismissible} > setShowRegister(false)} /> diff --git a/packages/console/src/components/AgentDetailClient.tsx b/packages/console/src/components/AgentDetailClient.tsx index c1aed21..ebf064e 100644 --- a/packages/console/src/components/AgentDetailClient.tsx +++ b/packages/console/src/components/AgentDetailClient.tsx @@ -12,10 +12,7 @@ import Modal from '@/components/ui/Modal'; import Link from 'next/link'; import type { AgentKey, Operation } from '@elydora/shared'; import { useTranslation } from 'react-i18next'; - -const HOOKS_INTEGRATIONS = new Set([ - 'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide', 'opencode', 'copilot', 'letta', -]); +import { ADAPTER_INTEGRATION_IDS } from '@/features/agent-registration/integrations'; export default function AgentDetailClient({ agentId }: { agentId: string }) { const { t } = useTranslation(); @@ -39,7 +36,9 @@ export default function AgentDetailClient({ agentId }: { agentId: string }) { const [revokeError, setRevokeError] = useState(null); const agent = agentData?.agent; - const supportsFreeze = !agent?.integration_type || HOOKS_INTEGRATIONS.has(agent.integration_type); + const supportsFreeze = agent + ? ADAPTER_INTEGRATION_IDS.has(agent.integration_type) + : false; const keys = agentData?.keys ?? []; const operations = opsData?.operations ?? []; diff --git a/packages/console/src/components/AgentRegistrationForm.tsx b/packages/console/src/components/AgentRegistrationForm.tsx index c2ec864..03f704a 100644 --- a/packages/console/src/components/AgentRegistrationForm.tsx +++ b/packages/console/src/components/AgentRegistrationForm.tsx @@ -1,1269 +1,180 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '@/lib/api'; import { useAuth } from '@/lib/auth'; -import type { RegisterAgentRequest } from '@elydora/shared'; - -const TOKEN_EXPIRATION_OPTIONS = [ - { labelKey: '24hours', seconds: 86400 }, - { labelKey: '7days', seconds: 604800 }, - { labelKey: '1month', seconds: 2592000 }, - { labelKey: '1year', seconds: 31536000 }, - { labelKey: 'custom', seconds: -1 }, - { labelKey: 'neverExpire', seconds: null }, -] as const; - -type ExpirationOption = (typeof TOKEN_EXPIRATION_OPTIONS)[number]; +import { generateAgentCredentials, type AgentCredentials } from '@/features/agent-registration/credentials'; +import { + assertRegistrationResponse, + RegistrationContractError, +} from '@/features/agent-registration/registration-contract'; +import type { IntegrationCatalogItem } from '@/features/agent-registration/integrations'; +import RegistrationProgress from './agent-registration/RegistrationProgress'; +import IntegrationStep from './agent-registration/IntegrationStep'; +import DetailsStep, { type AgentDetails } from './agent-registration/DetailsStep'; +import CredentialsStep from './agent-registration/CredentialsStep'; +import ConnectStep from './agent-registration/ConnectStep'; interface AgentRegistrationFormProps { - onSuccess: () => void; - onCancel: () => void; + readonly onRegistered: () => void; + readonly onDismissibleChange: (dismissible: boolean) => void; + readonly onSuccess: () => void; } -function base64urlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +type RegistrationState = + | { readonly step: 'integration' } + | { readonly step: 'details'; readonly integration: IntegrationCatalogItem } + | { + readonly step: 'credentials'; + readonly integration: IntegrationCatalogItem; + readonly credentials: AgentCredentials; + readonly token?: string; + } + | { + readonly step: 'connect'; + readonly integration: IntegrationCatalogItem; + readonly credentials: AgentCredentials; + readonly token: string; + }; + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; } -function generateAgentId(): string { - const id = crypto.randomUUID().split('-')[0]; - return `agent-${id}`; -} - -interface Credentials { - agentId: string; - kid: string; - publicKey: string; - privateKey: string; - orgId: string; -} - -// ─── Integration definitions ────────────────────────────────────────────────── - -type IntegrationCategory = 'supported' | 'sdk'; - -interface Integration { - id: string; - name: string; - description: string; - category: IntegrationCategory; - agentFlag?: string; -} - -const INTEGRATIONS: Integration[] = [ - // Supported (one-command install) - { id: 'claudecode', name: 'Claude Code', description: 'Anthropic CLI agent', category: 'supported', agentFlag: 'claudecode' }, - { id: 'cursor', name: 'Cursor', description: 'AI-powered code editor', category: 'supported', agentFlag: 'cursor' }, - { id: 'gemini', name: 'Gemini CLI', description: 'Google AI CLI agent', category: 'supported', agentFlag: 'gemini' }, - { id: 'kirocli', name: 'Kiro CLI', description: 'AWS AI CLI agent', category: 'supported', agentFlag: 'kirocli' }, - { id: 'kiroide', name: 'Kiro IDE', description: 'AWS spec-driven AI IDE', category: 'supported', agentFlag: 'kiroide' }, - { id: 'opencode', name: 'OpenCode', description: 'Open-source TUI agent', category: 'supported', agentFlag: 'opencode' }, - { id: 'copilot', name: 'Copilot CLI', description: 'GitHub Copilot CLI agent', category: 'supported', agentFlag: 'copilot' }, - { id: 'letta', name: 'Letta Code', description: 'Letta AI CLI agent', category: 'supported', agentFlag: 'letta' }, - // SDK Integration (code tutorial) - { id: 'codex', name: 'OpenAI Codex', description: 'OpenAI CLI agent', category: 'sdk' }, - { id: 'kimi', name: 'Kimi', description: 'Moonshot AI assistant', category: 'sdk' }, - { id: 'enterprise', name: 'Custom Enterprise Agent', description: 'Your own internal agent', category: 'sdk' }, - { id: 'gui', name: 'GUI Agent', description: 'Desktop or web-based agent', category: 'sdk' }, - { id: 'other', name: 'Other', description: 'Any custom integration', category: 'sdk' }, -]; - -// ─── Component ──────────────────────────────────────────────────────────────── - -export default function AgentRegistrationForm({ onSuccess, onCancel }: AgentRegistrationFormProps) { +export default function AgentRegistrationForm({ + onRegistered, + onDismissibleChange, + onSuccess, +}: AgentRegistrationFormProps) { const { t } = useTranslation(); const { user } = useAuth(); - const [displayName, setDisplayName] = useState(''); - const [responsibleEntity, setResponsibleEntity] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - // Success state - const [credentials, setCredentials] = useState(null); - const [copiedField, setCopiedField] = useState(null); - const [activeTab, setActiveTab] = useState<'node' | 'python' | 'go'>('node'); - - // Multi-step success flow - const [successStep, setSuccessStep] = useState<'credentials' | 'integration'>('credentials'); - const [selectedIntegration, setSelectedIntegration] = useState(null); - - // Token expiration - const [tokenExpiration, setTokenExpiration] = useState(TOKEN_EXPIRATION_OPTIONS[0]); - const [customDays, setCustomDays] = useState(''); - const [apiToken, setApiToken] = useState(null); + const [state, setState] = useState({ step: 'integration' }); + const [registering, setRegistering] = useState(false); + const [registrationError, setRegistrationError] = useState(null); + const [registrationBlocked, setRegistrationBlocked] = useState(false); const [issuingToken, setIssuingToken] = useState(false); const [tokenError, setTokenError] = useState(null); - const copyToClipboard = useCallback(async (text: string, field: string) => { - await navigator.clipboard.writeText(text); - setCopiedField(field); - setTimeout(() => setCopiedField(null), 2000); - }, []); + const register = async (details: AgentDetails) => { + if (state.step !== 'details') { + throw new Error('Agent registration can only start from the details step.'); + } - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - - if (!displayName.trim()) { - setError(t('agentRegistration.displayNameRequired')); - return; - } - - setSubmitting(true); - try { - // Generate Agent ID and keypair - const agentId = generateAgentId(); - const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); - const pkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); - const rawPublic = await crypto.subtle.exportKey('raw', keyPair.publicKey); - - const seed = new Uint8Array(pkcs8).slice(-32); - const kid = `${agentId}-key-1`; - const publicKey = base64urlEncode(rawPublic); - const privateKey = base64urlEncode(seed.buffer); - - // Register with backend - const body: RegisterAgentRequest = { - agent_id: agentId, - display_name: displayName.trim(), - responsible_entity: responsibleEntity.trim() || undefined, - keys: [{ kid, public_key: publicKey, algorithm: 'ed25519' }], - }; - await api.agents.register(body); - - // Show success screen with credentials - setCredentials({ - agentId, - kid, - publicKey, - privateKey, - orgId: user?.org_id ?? '', - }); - setSuccessStep('credentials'); - } catch (err) { - setError(err instanceof Error ? err.message : t('agentRegistration.failedToRegister')); - } finally { - setSubmitting(false); - } - }, - [displayName, responsibleEntity, user, t], - ); - - const handleIssueToken = useCallback(async () => { - setTokenError(null); - setIssuingToken(true); + setRegistering(true); + setRegistrationError(null); + setRegistrationBlocked(false); + onDismissibleChange(false); + const integration = state.integration; try { - let ttlSeconds: number | null; - if (tokenExpiration.seconds === null) { - ttlSeconds = null; - } else if (tokenExpiration.seconds === -1) { - const days = parseInt(customDays, 10); - if (!days || days < 1) { - setTokenError(t('agentRegistration.enterValidDays')); - return; - } - ttlSeconds = days * 86400; - } else { - ttlSeconds = tokenExpiration.seconds; + const credentials = await generateAgentCredentials(user?.org_id ?? ''); + const response = await api.agents.register({ + agent_id: credentials.agentId, + display_name: details.displayName, + responsible_entity: details.responsibleEntity, + integration_type: integration.id, + keys: [{ + kid: credentials.kid, + public_key: credentials.publicKey, + algorithm: 'ed25519', + }], + }); + assertRegistrationResponse(response, credentials, integration.id); + onRegistered(); + setState({ step: 'credentials', integration, credentials }); + } catch (error) { + onDismissibleChange(true); + setRegistrationBlocked(error instanceof RegistrationContractError); + setRegistrationError(errorMessage(error, t('agentRegistration.failedToRegister'))); + } finally { + setRegistering(false); + } + }; + + const issueToken = async (ttlSeconds: number | null) => { + if (state.step !== 'credentials') { + throw new Error('An API token can only be issued from the credentials step.'); + } + + setIssuingToken(true); + setTokenError(null); + try { + const response = await api.auth.issueToken(ttlSeconds); + if (!response.token.trim()) { + throw new Error('The token endpoint returned an empty token.'); } - const result = await api.auth.issueToken(ttlSeconds); - setApiToken(result.token); - } catch (err) { - setTokenError(err instanceof Error ? err.message : t('agentRegistration.failedToIssueToken')); + setState((current) => current.step === 'credentials' + ? { ...current, token: response.token } + : current); + } catch (error) { + setTokenError(errorMessage(error, t('agentRegistration.failedToIssueToken'))); } finally { setIssuingToken(false); } - }, [tokenExpiration, customDays, t]); + }; - // ─── Success Screen ─────────────────────────────────────────────────── - if (credentials) { - const creds = credentials; // narrowed non-null for closures - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787'; - // CLI install commands for supported integrations - const buildCliCommands = (integration: Integration) => ({ - node: `npx @elydora/sdk install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? ''}" --base_url "${apiBaseUrl}"`, - python: `pip install elydora && elydora install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? ''}" --base_url "${apiBaseUrl}"`, - go: `go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest && elydora install --agent ${integration.agentFlag} --org_id "${creds.orgId}" --agent_id "${creds.agentId}" --private_key "${creds.privateKey}" --kid "${creds.kid}" --token "${apiToken ?? ''}" --base-url "${apiBaseUrl}"`, + const continueToConnect = () => { + if (state.step !== 'credentials' || !state.token) { + throw new Error('A token is required before generating connection instructions.'); + } + setState({ + step: 'connect', + integration: state.integration, + credentials: state.credentials, + token: state.token, }); + }; - // SDK step-by-step tutorial (used for unsupported / SDK integrations) - type TutorialStep = { title: string; description: string; code: string }; - - // Daemon script content (shared across all languages — always Node.js) - const daemonCode = `#!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { recordOperation } from './client.mjs'; - -// Self-locate: daemon lives in ~/.elydora/{agentId}/ -const AGENT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const QUEUE_DIR = path.join(AGENT_DIR, 'queue'); -const PROCESSED_DIR = path.join(AGENT_DIR, 'processed'); -const PID_FILE = path.join(AGENT_DIR, 'daemon.pid'); -const LOG_FILE = path.join(AGENT_DIR, 'daemon.log'); - -fs.mkdirSync(QUEUE_DIR, { recursive: true }); -fs.mkdirSync(PROCESSED_DIR, { recursive: true }); -fs.writeFileSync(PID_FILE, String(process.pid)); - -function log(msg) { - fs.appendFileSync(LOG_FILE, \`[\${new Date().toISOString()}] \${msg}\\n\`); -} - -log(\`Daemon started (pid \${process.pid})\`); - -async function processFile(filePath) { - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - const { tool_name, tool_input, session_id } = data; - if (!tool_name) { log(\`SKIP \${filePath}: missing tool_name\`); return; } - await recordOperation({ - operationType: 'ai.tool_use', - subject: { session_id: session_id || 'unknown' }, - action: { tool: tool_name }, - payload: { tool_name, tool_input: tool_input || {}, session_id: session_id || 'unknown' }, - }); - fs.renameSync(filePath, path.join(PROCESSED_DIR, path.basename(filePath))); - log(\`OK \${path.basename(filePath)}\`); - } catch (err) { - log(\`ERR \${path.basename(filePath)}: \${err.message}\`); - } -} - -async function poll() { - try { - const files = fs.readdirSync(QUEUE_DIR).filter(f => f.endsWith('.json')).sort(); - for (const f of files) await processFile(path.join(QUEUE_DIR, f)); - } catch {} - setTimeout(poll, 500); -} -poll(); - -process.on('exit', () => { try { fs.unlinkSync(PID_FILE); } catch {} }); -process.on('SIGINT', () => process.exit()); -process.on('SIGTERM', () => process.exit());`; - - // Agent-specific persistent instructions content - function buildAgentInstructions(integrationId: string): TutorialStep { - const agentDir = `~/.elydora/${creds.agentId}`; - const agentDirWin = `$env:USERPROFILE\\.elydora\\${creds.agentId}`; - - const queueInstr = `After EVERY tool call, write a JSON file to the queue directory. -The daemon will pick it up automatically and submit to Elydora. - -IMPORTANT: Only access your own directory ${agentDir}/ — do NOT read or modify other agent directories. - -# macOS / Linux -echo '{"tool_name":"TOOL_NAME","tool_input":{},"session_id":"SESSION_ID"}' > ${agentDir}/queue/$(date +%s)_$$_$RANDOM.json - -# Windows (PowerShell) -'{"tool_name":"TOOL_NAME","tool_input":{},"session_id":"SESSION_ID"}' | Out-File "${agentDirWin}\\queue\\$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()).json" - -Every single tool use must be logged. No exceptions.`; - - if (integrationId === 'codex') { - return { - title: 'Start daemon + create Codex skill', - description: 'Start the queue daemon and create a Codex skill so every future session automatically logs tool use.', - code: `# Start the daemon: -# macOS / Linux: nohup node ${agentDir}/daemon.mjs >> ${agentDir}/daemon.log 2>&1 & -# Windows: Start-Process -NoNewWindow node -ArgumentList "${agentDirWin}\\daemon.mjs" - -# Create the skill directory: -# macOS / Linux: mkdir -p ~/.codex/skills/elydora-audit -# Windows: New-Item -ItemType Directory -Force "$env:USERPROFILE\\.codex\\skills\\elydora-audit" | Out-Null - -# Write ~/.codex/skills/elydora-audit/SKILL.md: ---- -name: elydora-audit -description: Log every tool use to Elydora. MUST be active at all times. ---- - -# Elydora Audit Logging (MANDATORY) - -${queueInstr}`, - }; - } - - if (integrationId === 'kimi') { - return { - title: 'Start daemon + add Kimi instructions', - description: 'Start the queue daemon and add logging instructions to KIMI.md so every future session logs tool use.', - code: `# Start the daemon: -# macOS / Linux: nohup node ${agentDir}/daemon.mjs >> ${agentDir}/daemon.log 2>&1 & -# Windows: Start-Process -NoNewWindow node -ArgumentList "${agentDirWin}\\daemon.mjs" - -# Append to KIMI.md in your project root (create if needed): - -## Elydora Audit Logging (MANDATORY) - -${queueInstr}`, - }; - } - - return { - title: 'Start daemon + add agent instructions', - description: 'Start the queue daemon and add logging instructions to your agent\'s persistent instructions file.', - code: `# Start the daemon: -# macOS / Linux: nohup node ${agentDir}/daemon.mjs >> ${agentDir}/daemon.log 2>&1 & -# Windows: Start-Process -NoNewWindow node -ArgumentList "${agentDirWin}\\daemon.mjs" - -# Add the following to your agent's instructions file (system prompt, instructions.md, AGENTS.md, etc.): - -## Elydora Audit Logging (MANDATORY) - -${queueInstr}`, - }; - } - - // Build tutorial steps: setup → client → daemon → start+instructions → log → verify - function buildSdkTutorialSteps(lang: 'node' | 'python' | 'go', integrationId: string): TutorialStep[] { - const agentDir = `~/.elydora/${creds.agentId}`; - const agentDirWin = `$env:USERPROFILE\\.elydora\\${creds.agentId}`; - - // Step 1: setup directory + install SDK (shared at ~/.elydora/, agent subdir created) - const setupStep: TutorialStep = lang === 'node' ? { - title: 'Create ~/.elydora/ and install SDK', - description: `Install the SDK at ~/.elydora/ (shared) and create your agent directory at ${agentDir}/.`, - code: `# macOS / Linux -mkdir -p ~/.elydora && cd ~/.elydora && npm init -y && npm install @elydora/sdk -mkdir -p ${agentDir} - -# Windows (PowerShell) -New-Item -ItemType Directory -Force "$env:USERPROFILE\\.elydora" | Out-Null -cd "$env:USERPROFILE\\.elydora"; npm init -y; npm install @elydora/sdk -New-Item -ItemType Directory -Force "${agentDirWin}" | Out-Null`, - } : lang === 'python' ? { - title: 'Create ~/.elydora/ and install SDK', - description: `Install the SDK at ~/.elydora/ (shared) and create your agent directory at ${agentDir}/.`, - code: `# macOS / Linux -mkdir -p ~/.elydora && cd ~/.elydora -npm init -y && npm install @elydora/sdk # daemon always uses Node.js -python -m venv .venv && source .venv/bin/activate && pip install elydora -mkdir -p ${agentDir} - -# Windows (PowerShell) -New-Item -ItemType Directory -Force "$env:USERPROFILE\\.elydora" | Out-Null -cd "$env:USERPROFILE\\.elydora" -npm init -y; npm install @elydora/sdk -python -m venv .venv; .venv\\Scripts\\Activate.ps1; pip install elydora -New-Item -ItemType Directory -Force "${agentDirWin}" | Out-Null`, - } : { - title: 'Create ~/.elydora/ and install SDK', - description: `Install the SDK at ~/.elydora/ (shared) and create your agent directory at ${agentDir}/.`, - code: `# macOS / Linux -mkdir -p ~/.elydora && cd ~/.elydora -npm init -y && npm install @elydora/sdk # daemon always uses Node.js -go mod init elydora-audit && go get github.com/Elydora-Infrastructure/Elydora-Go-SDK -mkdir -p ${agentDir} - -# Windows (PowerShell) -New-Item -ItemType Directory -Force "$env:USERPROFILE\\.elydora" | Out-Null -cd "$env:USERPROFILE\\.elydora" -npm init -y; npm install @elydora/sdk -go mod init elydora-audit; go get github.com/Elydora-Infrastructure/Elydora-Go-SDK -New-Item -ItemType Directory -Force "${agentDirWin}" | Out-Null`, - }; - - // Step 2: create client.mjs in agent subdirectory - const clientStep: TutorialStep = { - title: 'Create client.mjs', - description: `Create ${agentDir}/client.mjs — the pre-configured ElydoraClient used by the daemon.`, - code: `import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { ElydoraClient } from '@elydora/sdk'; - -const AGENT_ID = '${creds.agentId}'; -const AGENT_DIR = path.join(os.homedir(), '.elydora', AGENT_ID); -const CHAIN_STATE_PATH = path.join(AGENT_DIR, 'chain-state.json'); -const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - -function readChainState() { - try { return JSON.parse(fs.readFileSync(CHAIN_STATE_PATH, 'utf-8')).prev_chain_hash || ZERO_CHAIN_HASH; } - catch { return ZERO_CHAIN_HASH; } -} -function writeChainState(h) { - fs.mkdirSync(AGENT_DIR, { recursive: true }); - fs.writeFileSync(CHAIN_STATE_PATH, JSON.stringify({ prev_chain_hash: h })); -} - -export const client = new ElydoraClient({ - orgId: '${creds.orgId}', - agentId: AGENT_ID, - privateKey: '${creds.privateKey}', - kid: '${creds.kid}', - baseUrl: '${apiBaseUrl}', -}); -client.setToken('${apiToken ?? ''}'); -client.prevChainHash = readChainState(); - -export async function recordOperation({ operationType, subject, action, payload, verify = false }) { - const eor = client.createOperation({ operationType, subject, action, payload }); - const { receipt } = await client.submitOperation(eor); - if (receipt?.chain_hash) { client.prevChainHash = receipt.chain_hash; writeChainState(receipt.chain_hash); } - if (!verify) return { eor, receipt }; - const verification = await client.verifyOperation(receipt.operation_id); - return { eor, receipt, verification }; -}`, - }; - - // Step 3: create daemon.mjs in agent subdirectory - const daemonStep: TutorialStep = { - title: 'Create daemon.mjs', - description: `Create ${agentDir}/daemon.mjs — the queue watcher that automatically submits operations to Elydora.`, - code: daemonCode, - }; - - // Step 4: start daemon + agent instructions - const instructionsStep = buildAgentInstructions(integrationId); - - // Step 5: log tool use — primary (queue) + alternative (direct) - const logStep: TutorialStep = lang === 'node' ? { - title: 'Log tool use', - description: 'After every tool call, write a JSON file to the queue. The daemon handles the rest. Alternative: call recordOperation() directly for faster submission.', - code: `// PRIMARY — write to queue (daemon submits automatically) -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -const queueDir = path.join(os.homedir(), '.elydora', '${creds.agentId}', 'queue'); -fs.mkdirSync(queueDir, { recursive: true }); -fs.writeFileSync( - path.join(queueDir, \`\${Date.now()}-\${Math.random().toString(36).slice(2)}.json\`), - JSON.stringify({ tool_name: toolName, tool_input: toolInput, session_id: sessionId }), -); - -// ALTERNATIVE — direct SDK call (faster, bypasses daemon) -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -const { recordOperation } = await import( - pathToFileURL(join(homedir(), '.elydora', '${creds.agentId}', 'client.mjs')).href -); -await recordOperation({ - operationType: 'ai.tool_use', - subject: { session_id: sessionId }, - action: { tool: toolName }, - payload: { tool_name: toolName, tool_input: toolInput, session_id: sessionId }, -});`, - } : lang === 'python' ? { - title: 'Log tool use', - description: 'After every tool call, write a JSON file to the queue. The daemon handles the rest. Alternative: call record_operation() directly.', - code: `# PRIMARY — write to queue (daemon submits automatically) -import json, time -from pathlib import Path - -queue_dir = Path.home() / ".elydora" / "${creds.agentId}" / "queue" -queue_dir.mkdir(parents=True, exist_ok=True) -(queue_dir / f"{time.time_ns()}.json").write_text(json.dumps({ - "tool_name": tool_name, - "tool_input": tool_input, - "session_id": session_id, -})) - -# ALTERNATIVE — write to queue from a separate script (no daemon import needed) -import json, time, os - -queue_dir = os.path.join(os.path.expanduser("~"), ".elydora", "${creds.agentId}", "queue") -os.makedirs(queue_dir, exist_ok=True) -payload = {"tool_name": tool_name, "tool_input": tool_input, "session_id": session_id} -filename = f"{int(time.time() * 1e9)}.json" -with open(os.path.join(queue_dir, filename), "w") as f: - json.dump(payload, f)`, - } : { - title: 'Log tool use', - description: 'After every tool call, write a JSON file to the queue. The daemon handles the rest. Alternative: write to the queue directory directly.', - code: `// PRIMARY — write to queue (daemon submits automatically) -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -home, _ := os.UserHomeDir() -queueDir := filepath.Join(home, ".elydora", "${creds.agentId}", "queue") -os.MkdirAll(queueDir, 0755) -data, _ := json.Marshal(map[string]any{ - "tool_name": toolName, "tool_input": toolInput, "session_id": sessionId, -}) -os.WriteFile(filepath.Join(queueDir, fmt.Sprintf("%d.json", time.Now().UnixNano())), data, 0644) - -// ALTERNATIVE — write to queue from Go (same approach, different entry point) -queueDir := filepath.Join(os.Getenv("HOME"), ".elydora", "${creds.agentId}", "queue") -payload := fmt.Sprintf(\`{"tool_name":"%s","tool_input":{},"session_id":"session-1"}\`, toolName) -filename := fmt.Sprintf("%d.json", time.Now().UnixNano()) -os.WriteFile(filepath.Join(queueDir, filename), []byte(payload), 0644)`, - }; - - // Step 6: verify - const verifyStep: TutorialStep = lang === 'node' ? { - title: 'Verify (optional)', - description: 'Verify the integrity of a recorded operation.', - code: `import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -const { client } = await import( - pathToFileURL(join(homedir(), '.elydora', '${creds.agentId}', 'client.mjs')).href -); -const result = await client.verifyOperation(operationId); -console.log('Signature:', result.checks.signature); -console.log('Chain:', result.checks.chain);`, - } : lang === 'python' ? { - title: 'Verify (optional)', - description: 'Verify the integrity of a recorded operation.', - code: `# Verify via the Elydora console UI, or use urllib to call the API: -import urllib.request, json - -url = f"${apiBaseUrl}/v1/operations/{operation_id}/verify" -req = urllib.request.Request(url, headers={"Authorization": "Bearer YOUR_TOKEN"}) -result = json.loads(urllib.request.urlopen(req).read()) -print("Signature:", result["checks"]["signature"]) -print("Chain:", result["checks"]["chain"])`, - } : { - title: 'Verify (optional)', - description: 'Verify the integrity of a recorded operation.', - code: `result, _ := client.VerifyOperation(operationId) -fmt.Println("Signature:", result.Checks.Signature) -fmt.Println("Chain:", result.Checks.Chain)`, - }; - - return [setupStep, clientStep, daemonStep, instructionsStep, logStep, verifyStep]; - } - - // ─── Step 1: Credentials ────────────────────────────────────────── - if (successStep === 'credentials') { - return ( -
- {/* Header */} -
-
- - - -
-
-
{t('agentRegistration.agentRegistered')}
-
{creds.agentId}
-
-
- - {/* Private Key Warning */} -
-
- - - - - {t('agentRegistration.privateKeySaveNow')} - -
-

- {t('agentRegistration.privateKeyWarning')} -

-
- - {creds.privateKey} - - -
-
- - {/* API Token */} -
-
- - - - - {t('agentRegistration.apiToken')} - -
-

- {t('agentRegistration.apiTokenDesc')} -

- - {apiToken ? ( -
- - {apiToken} - - -
- ) : ( -
-
- -
- {TOKEN_EXPIRATION_OPTIONS.map((opt) => ( - - ))} -
-
- {tokenExpiration.seconds === -1 && ( -
- setCustomDays(e.target.value)} - placeholder={t('agentRegistration.customDays')} - className="w-40 px-3 py-1.5 bg-transparent border border-border font-mono text-[11px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors" - /> - {t('agentRegistration.days')} -
- )} - {tokenError && ( -

{tokenError}

- )} - -
- )} -
- - {/* Credentials Summary */} -
- {[ - { label: t('agentRegistration.credAgentId'), value: creds.agentId, field: 'agentId' }, - { label: t('agentRegistration.credKeyId'), value: creds.kid, field: 'kid' }, - { label: t('agentRegistration.credOrgId'), value: creds.orgId, field: 'orgId' }, - { label: t('agentRegistration.credAlgorithm'), value: 'Ed25519', field: 'algo' }, - ].map((item) => ( - - ))} -
- - {/* Continue to integration selector */} -
- -
-
- ); - } - - // ─── Step 2: Integration Selector ───────────────────────────────── - const supportedIntegrations = INTEGRATIONS.filter((i) => i.category === 'supported'); - const sdkIntegrations = INTEGRATIONS.filter((i) => i.category === 'sdk'); - - // If an integration is selected, show the install/code view - if (selectedIntegration) { - const isSupported = selectedIntegration.category === 'supported'; - const cliCmds = isSupported ? buildCliCommands(selectedIntegration) : null; - const steps = !isSupported ? buildSdkTutorialSteps(activeTab, selectedIntegration.id) : null; - - // AI-ready prompt for SDK integrations (copy-paste into any AI agent) - // Daemon-centric: setup → client → daemon → start + instructions → log via queue - function buildSdkAiPrompt(lang: 'node' | 'python' | 'go', integrationId: string): string { - const agentDir = `~/.elydora/${creds.agentId}`; - const agentDirWin = `$env:USERPROFILE\\.elydora\\${creds.agentId}`; - - const preamble = `Set up Elydora audit logging at ~/.elydora/${creds.agentId}/ (on Windows: $env:USERPROFILE\\.elydora\\${creds.agentId}\\). This is NOT project-specific — do NOT add anything to the current project's dependencies or files. Detect the current OS and use the appropriate commands below. - -IMPORTANT: Only access your own directory ${agentDir}/ — do NOT read or modify other agent directories under ~/.elydora/. - -IMPORTANT: Every queue JSON file MUST follow this exact structure: -{ "tool_name": "", "tool_input": , "session_id": "" }`; - - // Step 1: install SDK (shared at ~/.elydora/) + create agent subdir - const setupSteps: Record<'node' | 'python' | 'go', string> = { - node: `1. Create ~/.elydora/ and install the SDK, then create your agent directory: - -# macOS / Linux -mkdir -p ~/.elydora && cd ~/.elydora && npm init -y && npm install @elydora/sdk -mkdir -p ${agentDir} - -# Windows (PowerShell) -New-Item -ItemType Directory -Force "$env:USERPROFILE\\.elydora" | Out-Null; cd "$env:USERPROFILE\\.elydora"; npm init -y; npm install @elydora/sdk -New-Item -ItemType Directory -Force "${agentDirWin}" | Out-Null`, - - python: `1. Create ~/.elydora/ and install SDKs (Node.js required for daemon), then create your agent directory: - -# macOS / Linux -mkdir -p ~/.elydora && cd ~/.elydora -npm init -y && npm install @elydora/sdk -python -m venv .venv && source .venv/bin/activate && pip install elydora -mkdir -p ${agentDir} - -# Windows (PowerShell) -New-Item -ItemType Directory -Force "$env:USERPROFILE\\.elydora" | Out-Null; cd "$env:USERPROFILE\\.elydora" -npm init -y; npm install @elydora/sdk -python -m venv .venv; .venv\\Scripts\\Activate.ps1; pip install elydora -New-Item -ItemType Directory -Force "${agentDirWin}" | Out-Null`, - - go: `1. Create ~/.elydora/ and install SDKs (Node.js required for daemon), then create your agent directory: - -# macOS / Linux -mkdir -p ~/.elydora && cd ~/.elydora -npm init -y && npm install @elydora/sdk -go mod init elydora-audit && go get github.com/Elydora-Infrastructure/Elydora-Go-SDK -mkdir -p ${agentDir} - -# Windows (PowerShell) -New-Item -ItemType Directory -Force "$env:USERPROFILE\\.elydora" | Out-Null; cd "$env:USERPROFILE\\.elydora" -npm init -y; npm install @elydora/sdk -go mod init elydora-audit; go get github.com/Elydora-Infrastructure/Elydora-Go-SDK -New-Item -ItemType Directory -Force "${agentDirWin}" | Out-Null`, - }; - - // Step 2: client.mjs in agent subdirectory - const clientStep = `2. Create ${agentDir}/client.mjs with the pre-configured ElydoraClient: - -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { ElydoraClient } from '@elydora/sdk'; - -const AGENT_ID = '${creds.agentId}'; -const AGENT_DIR = path.join(os.homedir(), '.elydora', AGENT_ID); -const CHAIN_STATE_PATH = path.join(AGENT_DIR, 'chain-state.json'); -const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - -function readChainState() { - try { return JSON.parse(fs.readFileSync(CHAIN_STATE_PATH, 'utf-8')).prev_chain_hash || ZERO_CHAIN_HASH; } - catch { return ZERO_CHAIN_HASH; } -} -function writeChainState(h) { - fs.mkdirSync(AGENT_DIR, { recursive: true }); - fs.writeFileSync(CHAIN_STATE_PATH, JSON.stringify({ prev_chain_hash: h })); -} - -export const client = new ElydoraClient({ - orgId: '${creds.orgId}', - agentId: AGENT_ID, - privateKey: '${creds.privateKey}', - kid: '${creds.kid}', - baseUrl: '${apiBaseUrl}', -}); -client.setToken('${apiToken ?? ''}'); -client.prevChainHash = readChainState(); - -export async function recordOperation({ operationType, subject, action, payload, verify = false }) { - const eor = client.createOperation({ operationType, subject, action, payload }); - const { receipt } = await client.submitOperation(eor); - if (receipt?.chain_hash) { client.prevChainHash = receipt.chain_hash; writeChainState(receipt.chain_hash); } - if (!verify) return { eor, receipt }; - const verification = await client.verifyOperation(receipt.operation_id); - return { eor, receipt, verification }; -}`; - - // Step 3: daemon.mjs - const daemonStep = `3. Create ${agentDir}/daemon.mjs — the queue watcher that automatically submits operations: - -${daemonCode}`; - - // Step 4: start daemon + agent-specific persistent instructions - const queueInstr = `After EVERY tool call, write a JSON file to the queue directory. -The daemon will pick it up automatically and submit to Elydora. - -IMPORTANT: Only access your own directory ${agentDir}/ — do NOT read or modify other agent directories. - -# macOS / Linux -echo '{"tool_name":"TOOL_NAME","tool_input":{},"session_id":"SESSION_ID"}' > ${agentDir}/queue/$(date +%s)_$$_$RANDOM.json - -# Windows (PowerShell) -'{"tool_name":"TOOL_NAME","tool_input":{},"session_id":"SESSION_ID"}' | Out-File "${agentDirWin}\\queue\\$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()).json" - -Every single tool use must be logged. No exceptions.`; - - let instructionsStep: string; - if (integrationId === 'codex') { - instructionsStep = `4. Start the daemon and create a Codex skill for persistent auto-logging: - -# Start daemon: -# macOS / Linux: nohup node ${agentDir}/daemon.mjs >> ${agentDir}/daemon.log 2>&1 & -# Windows: Start-Process -NoNewWindow node -ArgumentList "${agentDirWin}\\daemon.mjs" - -# Create skill directory: -# macOS / Linux: mkdir -p ~/.codex/skills/elydora-audit -# Windows: New-Item -ItemType Directory -Force "$env:USERPROFILE\\.codex\\skills\\elydora-audit" | Out-Null - -# Write ~/.codex/skills/elydora-audit/SKILL.md: ---- -name: elydora-audit -description: Log every tool use to Elydora. MUST be active at all times. ---- - -# Elydora Audit Logging (MANDATORY) - -${queueInstr}`; - } else if (integrationId === 'kimi') { - instructionsStep = `4. Start the daemon and add Kimi instructions: - -# Start daemon: -# macOS / Linux: nohup node ${agentDir}/daemon.mjs >> ${agentDir}/daemon.log 2>&1 & -# Windows: Start-Process -NoNewWindow node -ArgumentList "${agentDirWin}\\daemon.mjs" - -# Append to KIMI.md in your project root (create if needed): - -## Elydora Audit Logging (MANDATORY) - -${queueInstr}`; - } else { - instructionsStep = `4. Start the daemon and add agent instructions: - -# Start daemon: -# macOS / Linux: nohup node ${agentDir}/daemon.mjs >> ${agentDir}/daemon.log 2>&1 & -# Windows: Start-Process -NoNewWindow node -ArgumentList "${agentDirWin}\\daemon.mjs" - -# Add to your agent's instructions file (system prompt, instructions.md, AGENTS.md, etc.): - -## Elydora Audit Logging (MANDATORY) - -${queueInstr}`; - } - - // Step 5: log tool use — PRIMARY (queue write) + ALTERNATIVE (direct SDK) - const logSteps: Record<'node' | 'python' | 'go', string> = { - node: `5. After EVERY tool use, write a JSON file to the queue (PRIMARY) or call recordOperation directly (ALTERNATIVE): - -PRIMARY — write to queue (daemon submits automatically): -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -const queueDir = path.join(os.homedir(), '.elydora', '${creds.agentId}', 'queue'); -fs.mkdirSync(queueDir, { recursive: true }); -fs.writeFileSync( - path.join(queueDir, \`\${Date.now()}-\${Math.random().toString(36).slice(2)}.json\`), - JSON.stringify({ tool_name: toolName, tool_input: toolInput, session_id: sessionId }), -); - -ALTERNATIVE — direct SDK call (faster, bypasses daemon): -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -const { recordOperation } = await import( - pathToFileURL(join(homedir(), '.elydora', '${creds.agentId}', 'client.mjs')).href -); -await recordOperation({ - operationType: 'ai.tool_use', - subject: { session_id: sessionId }, - action: { tool: toolName }, - payload: { tool_name: toolName, tool_input: toolInput, session_id: sessionId }, -});`, - - python: `5. After EVERY tool use, write a JSON file to the queue (PRIMARY) or call record_operation directly (ALTERNATIVE): - -PRIMARY — write to queue (daemon submits automatically): -import json, time -from pathlib import Path - -queue_dir = Path.home() / ".elydora" / "${creds.agentId}" / "queue" -queue_dir.mkdir(parents=True, exist_ok=True) -(queue_dir / f"{time.time_ns()}.json").write_text(json.dumps({ - "tool_name": tool_name, - "tool_input": tool_input, - "session_id": session_id, -})) - -ALTERNATIVE — write to queue from a separate script (no daemon import needed): -import json, time, os - -queue_dir = os.path.join(os.path.expanduser("~"), ".elydora", "${creds.agentId}", "queue") -os.makedirs(queue_dir, exist_ok=True) -payload = {"tool_name": tool_name, "tool_input": tool_input, "session_id": session_id} -filename = f"{int(time.time() * 1e9)}.json" -with open(os.path.join(queue_dir, filename), "w") as f: - json.dump(payload, f)`, - - go: `5. After EVERY tool use, write a JSON file to the queue (PRIMARY) or use the Go SDK directly (ALTERNATIVE): - -PRIMARY — write to queue (daemon submits automatically): -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -home, _ := os.UserHomeDir() -queueDir := filepath.Join(home, ".elydora", "${creds.agentId}", "queue") -os.MkdirAll(queueDir, 0755) -data, _ := json.Marshal(map[string]any{ - "tool_name": toolName, "tool_input": toolInput, "session_id": sessionId, -}) -os.WriteFile(filepath.Join(queueDir, fmt.Sprintf("%d.json", time.Now().UnixNano())), data, 0644) - -ALTERNATIVE — write to queue from Go (same approach, different entry point): -queueDir := filepath.Join(os.Getenv("HOME"), ".elydora", "${creds.agentId}", "queue") -payload := fmt.Sprintf(\`{"tool_name":"%s","tool_input":{},"session_id":"session-1"}\`, toolName) -filename := fmt.Sprintf("%d.json", time.Now().UnixNano()) -os.WriteFile(filepath.Join(queueDir, filename), []byte(payload), 0644)`, - }; - - const verifyNote = ` - -6. Verify everything is working: -# Check daemon is running: -# macOS / Linux: cat ${agentDir}/daemon.pid && echo "Daemon running" -# Windows: Get-Content "${agentDirWin}\\daemon.pid" - -# Check processed operations appear in ${agentDir}/processed/ -# You can also verify in the Elydora console at ${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/operations`; - - return `${preamble} - -${setupSteps[lang]} - -${clientStep} - -${daemonStep} - -${instructionsStep} - -${logSteps[lang]}${verifyNote}`; - } - - const aiPrompt = !isSupported ? buildSdkAiPrompt(activeTab, selectedIntegration.id) : null; - - return ( -
- {/* Back navigation */} -
- -
-
- {t('agentRegistration.step', { current: 2, total: 2 })} -
-
- - {/* Selected integration header */} -
-
- - {selectedIntegration.name.charAt(0)} - -
-
-
{selectedIntegration.name}
-
{t('integrations.' + selectedIntegration.id)}
-
- {!isSupported && aiPrompt && ( - - )} -
- - {/* AI prompt description for SDK integrations */} - {!isSupported && ( -
-

- {t('agentRegistration.aiReadyPromptDesc')} -

-
- )} - - {/* Language tabs */} -
- {(['node', 'python', 'go'] as const).map((tab) => ( - - ))} -
- - {/* ── Supported: single install command ── */} - {isSupported && cliCmds && ( - <> -
- {t('agentRegistration.install')} -
-
-
-                  {cliCmds[activeTab]}
-                
- -
- - )} - - {/* ── Unsupported: step-by-step SDK tutorial ── */} - {!isSupported && steps && ( -
- {steps.map((step, i) => ( -
- {/* Step header */} -
-
- {i + 1} -
-
-
{step.title}
-

{step.description}

-
-
- {/* Step code */} -
-
-                      {step.code}
-                    
- -
-
- ))} -
- )} - - {/* Done */} -
- -
-
- ); - } - - // Integration selector grid (no integration selected yet) - return ( -
- {/* Back to credentials */} -
- -
-
- {t('agentRegistration.step', { current: 2, total: 2 })} -
-
- - {/* Section header */} -
-
{t('agentRegistration.chooseIntegration')}
-

- {t('agentRegistration.chooseIntegrationDesc')} -

-
- - {/* Supported integrations */} -
-
- {t('agentRegistration.supported')} -
-
- {supportedIntegrations.map((integration) => ( - - ))} -
-
- - {/* SDK integrations */} -
-
- {t('agentRegistration.sdkIntegration')} -
-
- {sdkIntegrations.map((integration) => ( - - ))} -
-
- - {/* Skip */} -
- -
-
- ); - } - - // ─── Registration Form ──────────────────────────────────────────────── return ( -
- {error && ( -
- {error} -
+
+ + + {state.step === 'integration' && ( + setState({ step: 'details', integration })} + /> )} -
- - setDisplayName(e.target.value)} - placeholder={t('agentRegistration.displayNamePlaceholder')} - className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors" - required - autoFocus + {state.step === 'details' && ( + { + setRegistrationError(null); + setRegistrationBlocked(false); + setState({ step: 'integration' }); + }} /> -
+ )} -
- - setResponsibleEntity(e.target.value)} - placeholder={t('agentRegistration.responsibleEntityPlaceholder')} - className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors" + {state.step === 'credentials' && ( + -
+ )} -
- - -
- + {state.step === 'connect' && ( + setState({ + step: 'credentials', + integration: state.integration, + credentials: state.credentials, + token: state.token, + })} + onDone={onSuccess} + /> + )} +
); } diff --git a/packages/console/src/components/agent-registration/ConnectStep.tsx b/packages/console/src/components/agent-registration/ConnectStep.tsx new file mode 100644 index 0000000..9ccb00d --- /dev/null +++ b/packages/console/src/components/agent-registration/ConnectStep.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AgentCredentials } from '@/features/agent-registration/credentials'; +import type { IntegrationCatalogItem } from '@/features/agent-registration/integrations'; +import { + buildInstallInstructions, + SDK_LANGUAGES, + type SdkLanguage, +} from '@/features/agent-registration/install-instructions'; +import CopyControl from './CopyControl'; + +interface ConnectStepProps { + readonly integration: IntegrationCatalogItem; + readonly credentials: AgentCredentials; + readonly token: string; + readonly onBack: () => void; + readonly onDone: () => void; +} + +export default function ConnectStep({ + integration, + credentials, + token, + onBack, + onDone, +}: ConnectStepProps) { + const { t } = useTranslation(); + const [language, setLanguage] = useState('node'); + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787'; + const instructions = useMemo( + () => buildInstallInstructions(language, { integration, credentials, token, baseUrl }), + [baseUrl, credentials, integration, language, token], + ); + + return ( +
+
+ {t('agentRegistration.integration')} + {integration.name} +
+ +
+ {SDK_LANGUAGES.map((item) => ( + + ))} +
+ +
+
+

+ {integration.mode === 'adapter' ? t('agentRegistration.install') : t('agentRegistration.configure')} +

+ +
+
+          {instructions.setup}
+        
+
+ + {instructions.usage && ( +
+
+

{t('agentRegistration.record')}

+ +
+
+            {instructions.usage}
+          
+
+ )} + + {instructions.verify && ( +
+
+

{t('agentRegistration.verify')}

+ +
+ + {instructions.verify} + +
+ )} + + {instructions.postInstall && ( +
    + {instructions.postInstall.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+ )} + +
+ + +
+
+ ); +} diff --git a/packages/console/src/components/agent-registration/CopyControl.tsx b/packages/console/src/components/agent-registration/CopyControl.tsx new file mode 100644 index 0000000..3aac52d --- /dev/null +++ b/packages/console/src/components/agent-registration/CopyControl.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface CopyControlProps { + readonly value: string; + readonly className?: string; +} + +export default function CopyControl({ value, className = '' }: CopyControlProps) { + const { t } = useTranslation(); + const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); + + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setStatus('copied'); + } catch { + setStatus('failed'); + } + }; + + return ( + + + {status === 'failed' && ( + + {t('agentRegistration.copyFailed')} + + )} + + ); +} diff --git a/packages/console/src/components/agent-registration/CredentialsStep.tsx b/packages/console/src/components/agent-registration/CredentialsStep.tsx new file mode 100644 index 0000000..eb8ed83 --- /dev/null +++ b/packages/console/src/components/agent-registration/CredentialsStep.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AgentCredentials } from '@/features/agent-registration/credentials'; +import CopyControl from './CopyControl'; + +const EXPIRATION_OPTIONS = [ + { id: '24hours', seconds: 86_400 }, + { id: '7days', seconds: 604_800 }, + { id: '1month', seconds: 2_592_000 }, + { id: '1year', seconds: 31_536_000 }, + { id: 'custom', seconds: -1 }, + { id: 'neverExpire', seconds: null }, +] as const; +const MAX_CUSTOM_DAYS = 365; + +type ExpirationId = (typeof EXPIRATION_OPTIONS)[number]['id']; + +interface CredentialsStepProps { + readonly credentials: AgentCredentials; + readonly token?: string; + readonly issuingToken: boolean; + readonly tokenError: string | null; + readonly onIssueToken: (ttlSeconds: number | null) => void; + readonly onContinue: () => void; +} + +function CredentialRow({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+
{label}
+
{value}
+
+
+ ); +} +export default function CredentialsStep({ + credentials, + token, + issuingToken, + tokenError, + onIssueToken, + onContinue, +}: CredentialsStepProps) { + const { t } = useTranslation(); + const [expiration, setExpiration] = useState('24hours'); + const [customDays, setCustomDays] = useState(''); + const [validationError, setValidationError] = useState(null); + + const issueToken = () => { + const option = EXPIRATION_OPTIONS.find(({ id }) => id === expiration); + if (!option) { + setValidationError(t('agentRegistration.invalidExpiration')); + return; + } + + if (option.seconds === -1) { + const days = Number.parseInt(customDays, 10); + if (!Number.isSafeInteger(days) || days < 1 || days > MAX_CUSTOM_DAYS) { + setValidationError(t('agentRegistration.enterValidDays')); + return; + } + setValidationError(null); + onIssueToken(days * 86_400); + return; + } + + setValidationError(null); + onIssueToken(option.seconds); + }; + + return ( +
+
+ + + +
+ +
+
+

+ {t('agentRegistration.privateKeySaveNow')} +

+ +
+ + {credentials.privateKey} + +

+ {t('agentRegistration.privateKeyWarning')} +

+
+ +
+

+ {t('agentRegistration.apiToken')} +

+ + {token ? ( +
+
+ + {t('agentRegistration.tokenIssued')} + + +
+ + {token} + +
+ ) : ( +
+
+ + +
+ {expiration === 'custom' && ( +
+ + setCustomDays(event.target.value)} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[12px] text-ink focus:outline-none focus:border-ink" + disabled={issuingToken} + /> +
+ )} + +
+ )} + + {(validationError || tokenError) && ( +

+ {validationError ?? tokenError} +

+ )} +
+ +
+ +
+
+ ); +} diff --git a/packages/console/src/components/agent-registration/DetailsStep.tsx b/packages/console/src/components/agent-registration/DetailsStep.tsx new file mode 100644 index 0000000..a76b891 --- /dev/null +++ b/packages/console/src/components/agent-registration/DetailsStep.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { IntegrationCatalogItem } from '@/features/agent-registration/integrations'; + +export interface AgentDetails { + readonly displayName: string; + readonly responsibleEntity?: string; +} + +interface DetailsStepProps { + readonly integration: IntegrationCatalogItem; + readonly submitting: boolean; + readonly blocked: boolean; + readonly error: string | null; + readonly onSubmit: (details: AgentDetails) => void; + readonly onBack: () => void; +} + +export default function DetailsStep({ + integration, + submitting, + blocked, + error, + onSubmit, + onBack, +}: DetailsStepProps) { + const { t } = useTranslation(); + const [displayName, setDisplayName] = useState(''); + const [responsibleEntity, setResponsibleEntity] = useState(''); + const [validationError, setValidationError] = useState(null); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const normalizedName = displayName.trim(); + if (!normalizedName) { + setValidationError(t('agentRegistration.displayNameRequired')); + return; + } + setValidationError(null); + onSubmit({ + displayName: normalizedName, + responsibleEntity: responsibleEntity.trim() || undefined, + }); + }; + + return ( +
+
+ {t('agentRegistration.integration')} + {integration.name} +
+ + {(validationError || error) && ( +
+ {validationError ?? error} +
+ )} + +
+ + setDisplayName(event.target.value)} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink" + placeholder={t('agentRegistration.displayNamePlaceholder')} + disabled={submitting || blocked} + required + autoFocus + /> +
+ +
+ + setResponsibleEntity(event.target.value)} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink" + placeholder={t('agentRegistration.responsibleEntityPlaceholder')} + disabled={submitting || blocked} + /> +
+ +
+ + +
+
+ ); +} diff --git a/packages/console/src/components/agent-registration/IntegrationStep.tsx b/packages/console/src/components/agent-registration/IntegrationStep.tsx new file mode 100644 index 0000000..b099e08 --- /dev/null +++ b/packages/console/src/components/agent-registration/IntegrationStep.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useTranslation } from 'react-i18next'; +import { + INTEGRATION_CATALOG, + type IntegrationCatalogItem, + type IntegrationMode, +} from '@/features/agent-registration/integrations'; + +interface IntegrationStepProps { + readonly onSelect: (integration: IntegrationCatalogItem) => void; +} + +function IntegrationGroup({ + mode, + onSelect, +}: { + readonly mode: IntegrationMode; + readonly onSelect: (integration: IntegrationCatalogItem) => void; +}) { + const { t } = useTranslation(); + const integrations = INTEGRATION_CATALOG.filter((item) => item.mode === mode); + + return ( +
+

+ {t(`agentRegistration.integrationGroups.${mode}`)} +

+
+ {integrations.map((integration) => ( + + ))} +
+
+ ); +} +export default function IntegrationStep({ onSelect }: IntegrationStepProps) { + return ( +
+ + +
+ ); +} diff --git a/packages/console/src/components/agent-registration/RegistrationProgress.tsx b/packages/console/src/components/agent-registration/RegistrationProgress.tsx new file mode 100644 index 0000000..b0f1d38 --- /dev/null +++ b/packages/console/src/components/agent-registration/RegistrationProgress.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useTranslation } from 'react-i18next'; + +const STEPS = ['integration', 'details', 'credentials', 'connect'] as const; +export type RegistrationStep = (typeof STEPS)[number]; + +export default function RegistrationProgress({ current }: { current: RegistrationStep }) { + const { t } = useTranslation(); + const activeIndex = STEPS.indexOf(current); + + return ( +
    + {STEPS.map((step, index) => ( +
  1. + {index + 1} + {t(`agentRegistration.steps.${step}`)} +
  2. + ))} +
+ ); +} diff --git a/packages/console/src/components/ui/Modal.tsx b/packages/console/src/components/ui/Modal.tsx index 19cc422..3cbc862 100644 --- a/packages/console/src/components/ui/Modal.tsx +++ b/packages/console/src/components/ui/Modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useCallback, useRef } from 'react'; +import { useEffect, useRef, useId } from 'react'; import { createPortal } from 'react-dom'; interface ModalProps { @@ -9,28 +9,78 @@ interface ModalProps { title: string; children: React.ReactNode; width?: string; + dismissible?: boolean; } -export default function Modal({ isOpen, onClose, title, children, width = 'max-w-lg' }: ModalProps) { +export default function Modal({ + isOpen, + onClose, + title, + children, + width = 'max-w-lg', + dismissible = true, +}: ModalProps) { + const titleId = useId(); const overlayRef = useRef(null); + const dialogRef = useRef(null); + const previousFocusRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, - [onClose], - ); - + // Save previous focus on open; restore on unmount (close) useEffect(() => { - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - document.body.style.overflow = 'hidden'; - } + if (!isOpen) return; + + previousFocusRef.current = document.activeElement; + document.body.style.overflow = 'hidden'; + requestAnimationFrame(() => { + dialogRef.current?.focus(); + }); + return () => { - document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; + if (previousFocusRef.current instanceof HTMLElement) { + previousFocusRef.current.focus(); + } }; - }, [isOpen, handleKeyDown]); + }, [isOpen]); + + // Keydown listener — uses ref so it never changes identity + useEffect(() => { + if (!isOpen) return; + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape' && dismissible) { + onCloseRef.current(); + return; + } + + if (e.key === 'Tab' && dialogRef.current) { + const focusable = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [dismissible, isOpen]); if (!isOpen) return null; @@ -39,32 +89,40 @@ export default function Modal({ isOpen, onClose, title, children, width = 'max-w ref={overlayRef} className="fixed inset-0 md:left-[260px] z-[100] flex items-center justify-center modal-overlay" onClick={(e) => { - if (e.target === overlayRef.current) onClose(); + if (dismissible && e.target === overlayRef.current) onCloseRef.current(); }} + role="presentation" >
{/* Crosshair corners */} - - - - +