diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c6aaff2 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# PostgreSQL +POSTGRES_USER=elydora +POSTGRES_PASSWORD=elydora-secret-change-me +POSTGRES_DB=elydora +DATABASE_URL=postgresql://elydora:elydora-secret-change-me@postgres:5432/elydora + +# Redis +REDIS_URL=redis://redis:6379 + +# MinIO +MINIO_ROOT_USER=elydora +MINIO_ROOT_PASSWORD=elydora-minio-secret-change-me +MINIO_ENDPOINT=http://minio:9000 +MINIO_ACCESS_KEY=elydora +MINIO_SECRET_KEY=elydora-minio-secret-change-me +MINIO_BUCKET=elydora-evidence + +# Elydora API +PORT=8787 +ENVIRONMENT=production +API_VERSION=v1 +PROTOCOL_VERSION=1.0 +JWT_SECRET=change-me-to-a-random-string-at-least-32-chars +ELYDORA_SIGNING_KEY=change-me-base64url-ed25519-private-key +ALLOWED_ORIGINS=http://localhost:3000 + +# TSA (optional) +TSA_URL=http://timestamp.sectigo.com + +# Console +NEXT_PUBLIC_API_BASE_URL=http://localhost:8787 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..836955d --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +.next/ +out/ + +# Environment files +.env +.env.local +.env.*.local + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Coverage +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# Docker volumes (local dev) +postgres_data/ +redis_data/ +minio_data/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4491498 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,243 @@ +# Contributing to Elydora + +Thank you for your interest in contributing to Elydora — the open-source responsibility layer for AI agents. This document explains how to get a development environment running, our code style expectations, and how to submit changes. + +--- + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Development Setup](#development-setup) + - [Prerequisites](#prerequisites) + - [Running the API Server](#running-the-api-server) + - [Running the Console](#running-the-console) + - [SDK Development](#sdk-development) +- [Code Style](#code-style) +- [Pull Request Process](#pull-request-process) +- [Adding a New SDK Language](#adding-a-new-sdk-language) +- [Reporting Issues](#reporting-issues) + +--- + +## Code of Conduct + +This project follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) Code of Conduct. By participating you agree to abide by its terms. Report unacceptable behavior to the maintainers. + +--- + +## Development Setup + +### Prerequisites + +| Tool | Minimum version | +|------|----------------| +| Docker | 24+ | +| Docker Compose | v2.20+ | +| Node.js | 18+ (for Console / Node SDK) | +| Python | 3.9+ (for Python SDK) | +| Go | 1.21+ (for Go SDK) | + +### Running the API Server + +1. **Clone the repository** + + ```bash + git clone https://github.com/elydora/elydora.git + cd elydora + ``` + +2. **Configure environment variables** + + ```bash + cp .env.example .env + # Edit .env — required variables are documented inside the file + ``` + +3. **Generate cryptographic keys** + + ```bash + ./scripts/generate-keys.sh + ``` + +4. **Start all infrastructure services** + + ```bash + docker compose up -d + ``` + + This starts the API server, PostgreSQL, MinIO (object storage), and Redis. + +5. **Verify the server is healthy** + + ```bash + curl http://localhost:8787/v1/health + ``` + +### Running the Console + +The web console is a Next.js application located in `console/`. + +```bash +cd console +npm install +npm run dev +``` + +The console is served at `http://localhost:3000`. + +### SDK Development + +#### Node.js SDK (`sdks/node/`) + +```bash +cd sdks/node +npm install +npm run build # Compile TypeScript to dist/ +``` + +To run the CLI locally without installing: + +```bash +node dist/cli.js --help +``` + +#### Python SDK (`sdks/python/`) + +```bash +cd sdks/python +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +To run the CLI locally: + +```bash +elydora --help +``` + +Run type checks and tests: + +```bash +mypy elydora/ +pytest +``` + +#### Go SDK (`sdks/go/`) + +```bash +cd sdks/go +go build ./... +go test ./... +``` + +To build the CLI binary: + +```bash +go build -o elydora ./cmd/elydora +./elydora --help +``` + +--- + +## Code Style + +### General + +- Keep changes focused: one logical change per pull request. +- Write clear commit messages in the imperative mood (`Add X`, `Fix Y`, `Remove Z`). +- Avoid introducing new dependencies without discussion. +- Do not add placeholder data, mock data, or test fixtures to production code paths. + +### TypeScript (Node SDK) + +- Use strict TypeScript (`"strict": true` in tsconfig). +- Prefer `readonly` properties on interfaces. +- Use explicit return types on exported functions and methods. +- No `any` types — use `unknown` and narrow explicitly. + +### Python SDK + +- Target Python 3.9+ compatibility. +- Use type hints everywhere (checked with `mypy`). +- Follow PEP 8 with a line length of 100. +- Prefer keyword-only arguments for optional parameters. + +### Go SDK + +- Follow standard Go formatting (`gofmt`). +- Return `(T, error)` — never panic in library code. +- Keep the public API minimal; unexported helpers start with a lowercase letter. +- Document all exported symbols with Go doc comments. + +### API Server + +- All route handlers must validate input and return structured `{ error: { code, message, request_id } }` responses on failure. +- All operations must be authorized via the RBAC middleware before handler logic runs. +- Cryptographic operations (signing, verification) must use the canonical JCS representation. + +--- + +## Pull Request Process + +1. **Fork** the repository and create a feature branch from `main`. + + ```bash + git checkout -b feat/your-feature-name + ``` + +2. **Make your changes**, keeping commits atomic and well-described. + +3. **Test your changes** against a local Docker Compose environment. + +4. **Open a pull request** against the `main` branch. Fill in the PR template, including: + - A clear description of what changed and why. + - Steps to test the change locally. + - References to any related issues. + +5. **CI checks** run automatically. All checks must pass before a PR can be merged. + +6. A maintainer will review your PR. Address any requested changes in new commits (do not force-push to an open PR). + +7. Once approved and checks pass, a maintainer will merge using squash-merge. + +--- + +## Adding a New SDK Language + +Elydora welcomes official and community SDKs. A conformant SDK must implement the following: + +### Required capabilities + +| Capability | Details | +|------------|---------| +| EOR construction | Build a signed Elydora Operation Record per the protocol spec | +| Ed25519 signing | Sign the JCS-canonicalized EOR (excluding `signature` field) | +| Chain hashing | SHA-256 of `(prev_chain_hash \|\| payload_hash \|\| operation_id \|\| issued_at)` | +| Operation submission | `POST /v1/operations` | +| Configurable base URL | Must default to `https://api.elydora.com` but accept any URL | +| Retry logic | Retry on 429 and 5xx with exponential backoff | +| Structured errors | Surface `code`, `message`, `request_id` from error responses | + +### Recommended capabilities + +- Agent registration and management +- Audit query +- Epoch retrieval +- Export creation and download +- Auth helpers (register, login, token issuance) + +### Submission + +1. Place your SDK under `sdks//` in the monorepo. +2. Include a `README.md` with installation, quickstart, and API reference. +3. Include a conformance test that exercises at minimum: key generation, EOR construction, signing, and verification against a local server. +4. Open a pull request with the `sdk` label. + +--- + +## Reporting Issues + +- Use [GitHub Issues](https://github.com/elydora/elydora/issues) for bug reports and feature requests. +- For security vulnerabilities, **do not open a public issue**. Email the maintainers directly so the issue can be addressed before public disclosure. +- Include as much context as possible: Elydora version, SDK version, OS, steps to reproduce, and expected vs. actual behavior. diff --git a/LICENSE b/LICENSE index 261eeb9..dde0db2 100644 --- a/LICENSE +++ b/LICENSE @@ -32,36 +32,27 @@ not limited to compiled object code, generated documentation, and conversions to other media types. - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications + editorial revisions, annotations, elaborations, or other transformations represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "Contribution" includes any version of such + submission. - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, @@ -77,22 +68,21 @@ use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) + Contribution(s) alone or by the combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. + institute patent litigation against any entity (including a cross-claim + or counterclaim in a lawsuit) alleging that the Work or any patent + claims embodied in the Work constitute patent infringement, then any + patent licenses granted to You under this License for that Work shall + terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + (a) You must give any other recipients of the Work or Derivative Works + a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and @@ -104,28 +94,26 @@ the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text file + distributed as part of the Derivative Works; within the Source + form or documentation, if provided along with the Derivative + Works; or, within a display generated by the Derivative Works, + if and wherever such third-party notices normally appear. The + contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the + Derivative Works, and to permit persons to whom the Derivative Works + are furnished to do so, subject to the following conditions set forth + in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work @@ -147,7 +135,7 @@ implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any + appropriateness of using or reproducing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, @@ -155,23 +143,19 @@ unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a + incidental, or exemplary damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + License. However, in accepting such obligations, You may offer only + conditions consistent with this License. END OF TERMS AND CONDITIONS @@ -181,12 +165,10 @@ boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. + comment syntax for the syntax of the file format. Please also + include a "NOTICE" file as part of this distribution. - Copyright [yyyy] [name of copyright owner] + Copyright 2025-present Elydora Contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a9bf04 --- /dev/null +++ b/README.md @@ -0,0 +1,915 @@ +
+ +# Elydora + +**The Responsibility Layer for AI Agents** + +Tamper-evident audit trails with cryptographic proof for every AI agent action. + +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![Node SDK](https://img.shields.io/badge/node%20sdk-v1.1.0-green.svg)](sdks/node/) +[![Python SDK](https://img.shields.io/badge/python%20sdk-v1.1.0-green.svg)](sdks/python/) +[![Go SDK](https://img.shields.io/badge/go%20sdk-v0.1.0-green.svg)](sdks/go/) + +[Quick Start](#quick-start) · [Documentation](#documentation) · [API Reference](#api-reference) · [SDKs](#sdks) · [Contributing](#contributing) + +--- + +
+ +## What is Elydora? + +Elydora is an open-source **responsibility protocol for AI agents**. When an AI agent takes an action — reading a file, calling an API, modifying a database, sending a message — Elydora captures a cryptographically signed, tamper-evident record of that action. These records are chain-hashed together to form an unforgeable audit trail, then rolled up into Merkle tree epochs anchored to public timestamp authorities. + +The result is a complete, verifiable history of everything your AI agents have ever done, that no one — not even the platform operator — can silently alter. + +**Core protocol features:** + +- **Ed25519 signed operation records (EOR)** — every agent action is signed with the agent's private key before leaving the agent's process +- **Chain-hashed audit trails** — each record cryptographically commits to the previous record, making silent insertion, deletion, or reordering detectable +- **Merkle tree epoch rollups with TSA anchoring** — periodic epochs hash all records into a Merkle tree whose root is anchored via RFC 3161 Trusted Timestamping +- **Multi-tenant with RBAC** — fine-grained role-based access control with five distinct roles from read-only investigator to organization owner +- **Compliance exports (JSON / PDF)** — one-click export of the complete audit trail for any time range, agent, or operation type + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AI Agent Process │ +│ │ +│ ┌──────────────┐ createOperation() ┌──────────────────┐ │ +│ │ Agent Logic │ ─────────────────────▶│ Elydora SDK │ │ +│ │ │◀───────────────────── │ (Node/Python/Go)│ │ +│ └──────────────┘ submitOperation() └────────┬─────────┘ │ +│ │ Signs EOR │ +│ │ (Ed25519) │ +└───────────────────────────────────────────────────┼─────────────┘ + │ HTTPS + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Elydora API Server │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │ +│ │ Auth / RBAC │ │ Verify EOR │ │ Epoch Rollups │ │ +│ │ Middleware │ │ (signature + │ │ (Merkle trees + │ │ +│ │ │ │ chain hash) │ │ TSA anchoring) │ │ +│ └───────────────┘ └───────────────┘ └──────────────────┘ │ +│ │ │ │ │ +└───────────┼──────────────────┼─────────────────────┼────────────┘ + │ │ │ + ┌──────▼──────┐ ┌───────▼────────┐ ┌───────▼──────────┐ + │ PostgreSQL │ │ Object Store │ │ Redis │ + │ (metadata + │ │ (payloads + │ │ (nonce cache + │ + │ chain) │ │ receipts + │ │ rate limits) │ + │ │ │ exports) │ │ │ + └──────────────┘ └────────────────┘ └──────────────────┘ +``` + +--- + +## Quick Start + +### Prerequisites + +- Docker 24+ +- Docker Compose v2.20+ + +### One-Command Deploy + +```bash +git clone https://github.com/Elydora-Infrastructure/Elydora-Open-Source.git +cd Elydora-Open-Source + +# Copy and edit environment configuration +cp .env.example .env + +# Generate Ed25519 signing keys for the server and initial admin +./scripts/generate-keys.sh + +# Start all services (API, PostgreSQL, MinIO, Redis) +docker compose up -d + +# Verify the server is healthy +curl http://localhost:8787/v1/health +``` + +### Kubernetes Deploy + +```bash +helm install elydora ./helm/elydora \ + --namespace elydora \ + --create-namespace \ + -f values.yaml +``` + +--- + +## Project Structure + +``` +Elydora-Open-Source/ +├── packages/ +│ ├── server/ # Elydora API server +│ └── console/ # Next.js web management console +├── sdks/ +│ ├── node/ # @elydora/sdk — Node.js / TypeScript SDK +│ ├── python/ # elydora — Python SDK (sync + async) +│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK +├── helm/ +│ └── elydora/ # Kubernetes Helm chart +├── scripts/ # Key generation, migration, and utility scripts +├── CONTRIBUTING.md +├── LICENSE +└── README.md +``` + +| Package | Description | +|---------|-------------| +| `packages/server` | The core API server. Handles EOR ingestion, signature verification, chain validation, RBAC, epoch rollups, and compliance exports. | +| `packages/console` | A web-based management console for viewing agents, browsing the audit trail, managing exports, and administering users. | +| `sdks/node` | TypeScript SDK with full type coverage. Ships a CLI (`elydora`) and a programmatic client. Supports plugin hooks for popular AI coding agents. | +| `sdks/python` | Python SDK with both synchronous (`ElydoraClient`) and asynchronous (`AsyncElydoraClient`) clients. Requires Python 3.9+. | +| `sdks/go` | Go SDK with idiomatic error handling. Ships a CLI binary and embeds zero external runtime dependencies. | +| `helm/elydora` | Production-ready Helm chart for Kubernetes deployments with configurable replicas, resource limits, and secret management. | + +--- + +## Documentation + +### Registration and Authentication Flow + +``` +1. Register organization and first user + POST /v1/auth/register → { user, organization, token } + +2. Log in to retrieve a JWT + POST /v1/auth/login → { user, token } + +3. Issue a long-lived API token (optional) + POST /v1/auth/token → { token, expires_at } + +4. Register an agent (as integration_engineer) + POST /v1/agents/register → { agent, keys } + +5. Agent submits operations using its SDK client + POST /v1/operations → { receipt (EAR) } +``` + +### Operation Lifecycle + +``` +Agent SDK Server + │ │ │ + │ createOperation(params) │ │ + │ ─────────────────────────▶ │ │ + │ │ 1. Generate UUIDv7 op ID │ + │ │ 2. Generate 16-byte nonce │ + │ │ 3. Compute payload_hash │ + │ │ SHA-256(JCS(payload)) │ + │ │ 4. Compute chain_hash │ + │ │ SHA-256(prev|ph|id|ts) │ + │ │ 5. Sign JCS(EOR) w/ Ed25519 │ + │ │ 6. Update internal state │ + │ EOR (signed) │ │ + │ ◀───────────────────────── │ │ + │ │ │ + │ submitOperation(eor) │ │ + │ ─────────────────────────▶ │ POST /v1/operations │ + │ │ ────────────────────────────▶│ + │ │ │ Verify signature + │ │ │ Verify chain hash + │ │ │ Check TTL / replay + │ │ { receipt: EAR } │ + │ │ ◀────────────────────────────│ + │ SubmitOperationResponse │ │ + │ ◀───────────────────────── │ │ +``` + +--- + +## API Reference + +All endpoints are prefixed with the configured base URL (default: `https://api.elydora.com`). All authenticated endpoints require a `Bearer` token in the `Authorization` header. + +Errors are returned as: + +```json +{ + "error": { + "code": "INVALID_SIGNATURE", + "message": "Ed25519 signature verification failed", + "request_id": "01J..." + } +} +``` + +### Authentication + +#### `POST /v1/auth/register` + +Register a new user and create an organization. Returns a JWT for immediate use. + +**Request body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | User email address | +| `password` | string | yes | User password | +| `display_name` | string | no | Human-readable name for the user | +| `org_name` | string | no | Name for the new organization | + +**Response:** + +```json +{ + "user": { "user_id": "...", "email": "...", "role": "org_owner", ... }, + "organization": { "org_id": "...", "name": "..." }, + "token": "" +} +``` + +--- + +#### `POST /v1/auth/login` + +Authenticate an existing user. + +**Request body:** `{ "email": "...", "password": "..." }` + +**Response:** `{ "user": { ... }, "token": "" }` + +--- + +#### `GET /v1/auth/me` + +Return the authenticated user's profile. Requires a valid JWT. + +**Response:** `{ "user": { "user_id": "...", "email": "...", "role": "...", ... } }` + +--- + +#### `POST /v1/auth/token` + +Issue a long-lived API token. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `ttl_seconds` | integer \| null | no | Token lifetime in seconds. `null` for non-expiring. | + +**Response:** `{ "token": "", "expires_at": | null }` + +--- + +### Agents + +Agents are the AI processes that submit operations. An agent has one or more Ed25519 key pairs registered with the server. + +#### `POST /v1/agents/register` + +Register a new agent. Required role: **integration_engineer**. + +**Request body:** + +```json +{ + "agent_id": "my-agent-v1", + "display_name": "Customer Support Bot", + "responsible_entity": "engineering-team@example.com", + "keys": [ + { + "kid": "my-agent-v1-key-v1", + "public_key": "", + "algorithm": "ed25519" + } + ] +} +``` + +**Response:** `{ "agent": { ... }, "keys": [ ... ] }` + +--- + +#### `GET /v1/agents` + +List all agents for the organization. Required role: **readonly_investigator**. + +**Response:** `{ "agents": [ { "agent_id": "...", "status": "active", ... } ] }` + +--- + +#### `GET /v1/agents/:id` + +Retrieve a specific agent and its keys. Required role: **readonly_investigator**. + +**Response:** `{ "agent": { ... }, "keys": [ ... ] }` + +--- + +#### `PATCH /v1/agents/:id` + +Update agent metadata. Required role: **integration_engineer**. + +--- + +#### `DELETE /v1/agents/:id` + +Permanently delete an agent. Required role: **security_admin**. + +**Response:** `{ "deleted": true }` + +--- + +#### `POST /v1/agents/:id/freeze` + +Freeze an agent, preventing it from submitting new operations. Required role: **security_admin**. + +**Request body:** `{ "reason": "Unusual activity detected" }` + +--- + +#### `POST /v1/agents/:id/unfreeze` + +Re-activate a frozen agent. Required role: **security_admin**. + +**Request body:** `{ "reason": "Investigation complete" }` + +**Response:** `{ "agent": { "status": "active", ... } }` + +--- + +#### `POST /v1/agents/:id/revoke` + +Revoke a specific signing key. The agent remains registered but the key can no longer be used to sign new operations. Required role: **security_admin**. + +**Request body:** `{ "kid": "my-agent-v1-key-v1", "reason": "Key rotation" }` + +--- + +### Operations + +#### `POST /v1/operations` + +Submit a signed Elydora Operation Record (EOR). Required role: **integration_engineer**. The EOR must be constructed and signed by the agent SDK — never by the server. + +**Request body:** A complete EOR (see [Protocol Specification](#protocol-specification)). + +**Response:** `{ "receipt": }` — the server-issued Elydora Acknowledgment Receipt. + +--- + +#### `GET /v1/operations/:id` + +Retrieve a stored operation and its receipt. Required role: **readonly_investigator**. + +**Response:** `{ "operation": { ... }, "receipt": { ... } }` + +--- + +#### `POST /v1/operations/:id/verify` + +Re-verify a stored operation's cryptographic integrity. Required role: **readonly_investigator**. + +**Response:** + +```json +{ + "valid": true, + "checks": { + "signature": true, + "chain": true, + "receipt": true, + "merkle": true + }, + "errors": [] +} +``` + +--- + +### Audit + +#### `POST /v1/audit/query` + +Query the tamper-evident audit log with filtering and cursor-based pagination. Required role: **compliance_auditor**. + +**Request body:** + +| Field | Type | Description | +|-------|------|-------------| +| `agent_id` | string | Filter by agent | +| `operation_type` | string | Filter by operation type | +| `start_time` | integer | Start of time range (Unix ms) | +| `end_time` | integer | End of time range (Unix ms) | +| `cursor` | string | Pagination cursor from a previous response | +| `limit` | integer | Maximum number of records to return | + +**Response:** `{ "operations": [ ... ], "cursor": "...", "total_count": 1042 }` + +--- + +### Epochs + +Epochs are periodic rollups that Merkle-hash all operations in a time window and anchor the root to a public timestamp authority (RFC 3161 TSA). + +#### `GET /v1/epochs` + +List all epoch records for the organization. Required role: **readonly_investigator**. + +**Response:** `{ "epochs": [ { "epoch_id": "...", "root_hash": "...", "leaf_count": 500, ... } ] }` + +--- + +#### `GET /v1/epochs/:id` + +Retrieve a specific epoch including its TSA anchor. Required role: **readonly_investigator**. + +**Response:** + +```json +{ + "epoch": { + "epoch_id": "...", + "org_id": "...", + "start_time": 1700000000000, + "end_time": 1700003600000, + "root_hash": "", + "leaf_count": 500, + "created_at": 1700003601234 + }, + "anchor": { + "tsa_token": "" + } +} +``` + +--- + +### Exports + +#### `POST /v1/exports` + +Create an asynchronous compliance export job. Required role: **compliance_auditor**. + +**Request body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `start_time` | integer | yes | Start of export window (Unix ms) | +| `end_time` | integer | yes | End of export window (Unix ms) | +| `format` | `"json"` \| `"pdf"` | yes | Export format | +| `agent_id` | string | no | Restrict to one agent | +| `operation_type` | string | no | Restrict to one operation type | + +**Response:** `{ "export": { "export_id": "...", "status": "queued", ... } }` + +--- + +#### `GET /v1/exports` + +List all exports for the organization. Required role: **compliance_auditor**. + +--- + +#### `GET /v1/exports/:id` + +Poll export status and retrieve a download URL once complete. Required role: **compliance_auditor**. + +**Response:** + +```json +{ + "export": { "export_id": "...", "status": "done", "completed_at": 1700003700000 }, + "download_url": "https://..." +} +``` + +--- + +#### `GET /v1/exports/:id/download` + +Stream the export file directly. Required role: **compliance_auditor**. + +--- + +### Public Endpoints + +#### `GET /v1/health` + +Health check. No authentication required. + +**Response:** + +```json +{ + "status": "ok", + "version": "1.1.0", + "protocol_version": "1.0", + "timestamp": 1700000000000 +} +``` + +--- + +#### `GET /.well-known/elydora/jwks.json` + +The server's JSON Web Key Set. Used by external verifiers to validate EAR signatures. No authentication required. + +**Response:** `{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "...", "kid": "...", "use": "sig", "alg": "EdDSA" } ] }` + +--- + +## SDKs + +### Node.js / TypeScript + +**Installation:** + +```bash +npm install @elydora/sdk +``` + +**Requirements:** Node.js 18+ + +**Quickstart:** + +```typescript +import { ElydoraClient } from '@elydora/sdk'; +import { generateKeyPair } from '@elydora/sdk/crypto'; + +// 1. Register your organization (one-time setup) +const { user, token } = await ElydoraClient.register( + 'http://localhost:8787', + 'admin@example.com', + 'your-password', + 'Admin User', + 'Acme Corp', +); + +// 2. Create an agent client +const client = new ElydoraClient({ + orgId: user.org_id, + agentId: 'order-processor-v1', + privateKey: process.env.AGENT_PRIVATE_KEY!, + baseUrl: 'http://localhost:8787', // omit for https://api.elydora.com +}); +client.setToken(token); + +// 3. Register the agent with its public key +await client.registerAgent({ + agent_id: 'order-processor-v1', + display_name: 'Order Processor', + keys: [{ kid: 'order-processor-v1-key-v1', public_key: client.getPublicKey(), algorithm: 'ed25519' }], +}); + +// 4. Submit an operation +const eor = client.createOperation({ + operationType: 'order.process', + subject: { order_id: 'ord-123', customer_id: 'cust-456' }, + action: { verb: 'charge', amount_cents: 4999, currency: 'USD' }, +}); +const { receipt } = await client.submitOperation(eor); +console.log('Accepted. seq_no:', receipt.seq_no); +``` + +**CLI:** + +```bash +npx elydora health --base-url http://localhost:8787 +npx elydora keygen # Generate a new Ed25519 key pair +npx elydora submit-op ... # Submit an operation from the CLI +``` + +--- + +### Python + +**Installation:** + +```bash +pip install elydora +``` + +**Requirements:** Python 3.9+, `requests`, `aiohttp`, `cryptography` + +**Quickstart (synchronous):** + +```python +from elydora import ElydoraClient + +# One-time registration +resp = ElydoraClient.register( + "http://localhost:8787", + "admin@example.com", + "your-password", + display_name="Admin User", + org_name="Acme Corp", +) + +# Create agent client +client = ElydoraClient( + org_id=resp["user"]["org_id"], + agent_id="order-processor-v1", + private_key=os.environ["AGENT_PRIVATE_KEY"], + base_url="http://localhost:8787", # omit for https://api.elydora.com + token=resp["token"], +) + +# Register the agent and submit operations +from elydora.crypto import derive_public_key +client.register_agent({ + "agent_id": "order-processor-v1", + "display_name": "Order Processor", + "keys": [{"kid": "order-processor-v1-key-v1", + "public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]), + "algorithm": "ed25519"}], +}) + +eor = client.create_operation( + operation_type="order.process", + subject={"order_id": "ord-123", "customer_id": "cust-456"}, + action={"verb": "charge", "amount_cents": 4999, "currency": "USD"}, +) +receipt = client.submit_operation(eor) +print("Accepted. seq_no:", receipt["receipt"]["seq_no"]) +``` + +**Quickstart (asynchronous):** + +```python +from elydora import AsyncElydoraClient + +async def main(): + client = AsyncElydoraClient( + org_id="...", agent_id="...", private_key="...", + base_url="http://localhost:8787", + ) + eor = client.create_operation(operation_type="file.read", subject={...}, action={...}) + receipt = await client.submit_operation(eor) +``` + +**CLI:** + +```bash +elydora health --base-url http://localhost:8787 +elydora keygen +``` + +--- + +### Go + +**Installation:** + +```bash +go get github.com/Elydora-Infrastructure/Elydora-Go-SDK +``` + +**Requirements:** Go 1.21+ + +**Quickstart:** + +```go +package main + +import ( + "fmt" + "os" + + elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK" +) + +func main() { + // One-time registration + reg, err := elydora.Register( + "http://localhost:8787", + "admin@example.com", + "your-password", + elydora.WithDisplayName("Admin User"), + elydora.WithOrgName("Acme Corp"), + ) + if err != nil { + panic(err) + } + + // Create agent client + client, err := elydora.NewClient(&elydora.Config{ + OrgID: reg.User.OrgID, + AgentID: "order-processor-v1", + PrivateKey: os.Getenv("AGENT_PRIVATE_KEY"), + BaseURL: "http://localhost:8787", // omit for https://api.elydora.com + Token: reg.Token, + }) + if err != nil { + panic(err) + } + + // Submit an operation + eor, err := client.CreateOperation(&elydora.CreateOperationParams{ + OperationType: "order.process", + Subject: map[string]interface{}{"order_id": "ord-123"}, + Action: map[string]interface{}{"verb": "charge", "amount_cents": 4999}, + }) + if err != nil { + panic(err) + } + + result, err := client.SubmitOperation(eor) + if err != nil { + panic(err) + } + fmt.Println("Accepted. seq_no:", result.Receipt.SeqNo) +} +``` + +**CLI:** + +```bash +go run ./cmd/elydora health --base-url http://localhost:8787 +go run ./cmd/elydora keygen +``` + +--- + +## Protocol Specification + +### EOR — Elydora Operation Record + +The EOR is the fundamental unit of the protocol. It is constructed and signed by the agent SDK before being sent to the server. The server never modifies an EOR after signing. + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `op_version` | `"1.0"` | Protocol version | +| `operation_id` | string | UUIDv7 — monotonic, time-ordered unique ID | +| `org_id` | string | Organization identifier | +| `agent_id` | string | Agent identifier | +| `issued_at` | integer | Unix timestamp in milliseconds when the EOR was constructed | +| `ttl_ms` | integer | Validity window in milliseconds (default: 30,000) | +| `nonce` | string | 16 random bytes, base64url-encoded, prevents replay | +| `operation_type` | string | Application-defined operation category (e.g. `"file.read"`, `"order.process"`) | +| `subject` | object | The entity the operation acts upon | +| `action` | object | Description of what was done | +| `payload` | object \| string \| null | Optional structured payload | +| `payload_hash` | string | SHA-256 of JCS-canonicalized `payload`, base64url | +| `prev_chain_hash` | string | Chain hash of the immediately preceding EOR for this agent | +| `agent_pubkey_kid` | string | Key ID of the signing key | +| `signature` | string | Ed25519 signature over JCS-canonical EOR (excluding `signature` field), base64url | + +**Signing:** + +``` +canonical_bytes = JCS_canonicalize(EOR without "signature" field) +signature = Ed25519_sign(agent_private_key, canonical_bytes) +``` + +**Chain hashing:** + +``` +chain_hash = SHA-256( + prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + issued_at +) +``` + +All inputs are UTF-8 strings joined with `|`. The genesis (first) `prev_chain_hash` for every agent is the base64url encoding of 32 zero bytes: `AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=`. + +**Payload hashing:** + +``` +payload_hash = SHA-256(JCS_canonicalize(payload)) +``` + +If `payload` is `null`, the hash is of the JCS canonical form of `null`. + +--- + +### EAR — Elydora Acknowledgment Receipt + +The EAR is issued by the server upon accepting a valid EOR. It constitutes server-side proof of ingestion. + +| Field | Type | Description | +|-------|------|-------------| +| `receipt_version` | string | Receipt protocol version | +| `receipt_id` | string | Unique receipt identifier | +| `operation_id` | string | Echo of the operation ID | +| `org_id` | string | Organization ID | +| `agent_id` | string | Agent ID | +| `server_received_at` | integer | Unix ms when the server accepted the EOR | +| `seq_no` | integer | Monotonically increasing sequence number for this agent | +| `chain_hash` | string | Server-computed chain hash confirming the EOR was inserted in the correct position | +| `queue_message_id` | string | Internal durable queue message identifier | +| `receipt_hash` | string | SHA-256 of the canonical receipt content | +| `elydora_kid` | string | Key ID of the server's signing key | +| `elydora_signature` | string | Server's Ed25519 signature over the receipt | + +--- + +### EER — Elydora Epoch Record + +Epochs are created periodically. All EOR chain hashes within the epoch window are hashed into a binary Merkle tree. The root hash is then anchored to an RFC 3161-compliant Trusted Timestamp Authority. + +| Field | Type | Description | +|-------|------|-------------| +| `epoch_id` | string | Unique epoch identifier | +| `org_id` | string | Organization ID | +| `start_time` | integer | Epoch start (Unix ms) | +| `end_time` | integer | Epoch end (Unix ms) | +| `root_hash` | string | Merkle root of all chain hashes in the window | +| `leaf_count` | integer | Number of operations included | +| `created_at` | integer | When the epoch record was created | + +The TSA token is an RFC 3161 `TimeStampToken` proving the Merkle root existed at or before the timestamp authority's stated time. + +--- + +## RBAC Roles + +| Role | Level | Capabilities | +|------|-------|-------------| +| `org_owner` | 50 | Full administrative access; can manage all users, roles, agents, and organization settings | +| `security_admin` | 40 | Agent lifecycle management: freeze, unfreeze, revoke keys, delete agents | +| `compliance_auditor` | 30 | Read audit log, query operations, create and download compliance exports | +| `integration_engineer` | 20 | Register agents, submit operations, update agent metadata | +| `readonly_investigator` | 10 | Read-only access to agents, operations, epochs; cannot modify any state | + +Higher-level roles inherit all capabilities of lower-level roles. + +--- + +## Configuration + +Elydora is configured via environment variables. Copy `.env.example` to `.env` and configure the following: + +### Required + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | PostgreSQL connection string | +| `REDIS_URL` | Redis connection string | +| `STORAGE_ENDPOINT` | Object storage endpoint (S3-compatible, e.g. MinIO) | +| `STORAGE_BUCKET` | Bucket name for payloads, receipts, and exports | +| `STORAGE_ACCESS_KEY_ID` | Object storage access key | +| `STORAGE_SECRET_ACCESS_KEY` | Object storage secret key | +| `JWT_SECRET` | HMAC-SHA256 secret for signing user JWTs | +| `SERVER_PRIVATE_KEY` | Base64url-encoded Ed25519 seed for signing EARs and JWKS | + +### Optional + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8787` | HTTP port the API server listens on | +| `EPOCH_INTERVAL_SECONDS` | `3600` | How often epoch rollups are created | +| `TSA_URL` | — | RFC 3161 Trusted Timestamp Authority URL. If unset, TSA anchoring is disabled. | +| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) | +| `MAX_PAYLOAD_BYTES` | `65536` | Maximum size of an EOR payload | +| `OPERATION_TTL_MAX_MS` | `300000` | Maximum allowable `ttl_ms` in submitted EORs | +| `CORS_ORIGINS` | `*` | Allowed CORS origins for the console | + +--- + +## Security + +### Cryptographic Guarantees + +**Ed25519 signatures** — Every EOR is signed by the agent's Ed25519 private key before leaving the agent's process. The server verifies this signature against the registered public key before accepting the record. A compromised server cannot forge valid EORs without access to the agent's private key. + +**SHA-256 chain hashing** — Each EOR commits to the hash of the previous EOR, the payload, the operation ID, and the timestamp. Silent insertion, deletion, or reordering of records in the chain is detectable because any change invalidates all subsequent chain hashes. + +**PBKDF2-SHA256 password hashing** — User passwords are hashed with PBKDF2-SHA256 before storage. + +**HMAC-SHA256 JWT signing** — User sessions are signed with a server-side HMAC secret. Tokens cannot be forged without the secret. + +**RFC 3161 TSA anchoring** — Epoch Merkle roots are submitted to a public timestamp authority, providing independent third-party attestation of when records existed. This prevents retroactive alteration of historical data even by the platform operator. + +**JCS canonicalization (RFC 8785)** — All signing operations use JSON Canonicalization Scheme, eliminating ambiguity in JSON serialization and ensuring signatures are deterministic across all SDK implementations. + +### Key Management + +- Agent private keys never leave the agent process. The SDK generates signatures locally. +- The server holds only the agent's public key. +- Server signing keys for EARs and JWKS are stored as environment variables and rotated via `kid` versioning. +- Key rotation is supported: register additional keys with the same agent and revoke old keys without disrupting the audit trail. + +### Replay Protection + +- Each EOR includes a cryptographically random 16-byte nonce. +- The server maintains a nonce cache in Redis to detect and reject replayed operations within the TTL window. +- The `ttl_ms` field defines the validity window; operations submitted after `issued_at + ttl_ms` are rejected. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style guidelines, and the pull request process. + +Contributions are welcome across all parts of the project: server, console, SDKs, Helm chart, documentation, and new SDK language implementations. + +--- + +## License + +Copyright 2025-present Elydora Contributors + +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9340a9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,297 @@ +version: '3.9' + +services: + # ============================================================ + # PostgreSQL + # ============================================================ + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-elydora} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-elydora-secret-change-me} + POSTGRES_DB: ${POSTGRES_DB:-elydora} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-elydora} -d ${POSTGRES_DB:-elydora}"] + interval: 5s + timeout: 5s + retries: 10 + + # ============================================================ + # Redis + # ============================================================ + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + # ============================================================ + # MinIO (S3-compatible object storage) + # ============================================================ + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-elydora} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-elydora-minio-secret-change-me} + volumes: + - minio_data:/data + ports: + - "9001:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 10 + + # ============================================================ + # MinIO init — creates the evidence bucket + # ============================================================ + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-elydora} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-elydora-minio-secret-change-me} + MINIO_BUCKET: ${MINIO_BUCKET:-elydora-evidence} + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; + mc mb --ignore-existing local/$${MINIO_BUCKET}; + echo 'MinIO bucket initialized.'; + " + restart: on-failure + + # ============================================================ + # DB migrate — runs PostgreSQL migrations + # ============================================================ + db-migrate: + image: postgres:16-alpine + depends_on: + postgres: + condition: service_healthy + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:-elydora-secret-change-me} + command: > + psql -h postgres -U ${POSTGRES_USER:-elydora} -d ${POSTGRES_DB:-elydora} -c " + CREATE TABLE IF NOT EXISTS schema_versions ( + version BIGINT PRIMARY KEY, + applied_at BIGINT NOT NULL, + description TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS organizations ( + org_id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT NOT NULL PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations (org_id), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'org_owner' + CHECK (role IN ('org_owner', 'security_admin', 'compliance_auditor', 'readonly_investigator', 'integration_engineer')), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'suspended')), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_users_org_id ON users (org_id); + CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + + CREATE TABLE IF NOT EXISTS agents ( + agent_id TEXT NOT NULL PRIMARY KEY, + 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', + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'frozen', 'revoked')), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_agents_org_id ON agents (org_id); + + CREATE TABLE IF NOT EXISTS agent_keys ( + kid TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents (agent_id), + public_key TEXT NOT NULL, + algorithm TEXT NOT NULL DEFAULT 'ed25519' + CHECK (algorithm = 'ed25519'), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'retired', 'revoked')), + created_at BIGINT NOT NULL, + retired_at BIGINT + ); + + CREATE INDEX IF NOT EXISTS idx_agent_keys_agent_id ON agent_keys (agent_id); + + CREATE TABLE IF NOT EXISTS operations ( + operation_id TEXT NOT NULL PRIMARY KEY, + org_id TEXT NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents (agent_id), + seq_no BIGINT NOT NULL, + operation_type TEXT NOT NULL, + issued_at BIGINT NOT NULL, + ttl_ms BIGINT NOT NULL, + nonce TEXT NOT NULL, + subject TEXT NOT NULL, + action TEXT NOT NULL, + payload_hash TEXT NOT NULL, + prev_chain_hash TEXT NOT NULL, + chain_hash TEXT NOT NULL, + agent_pubkey_kid TEXT NOT NULL, + signature TEXT NOT NULL, + r2_payload_key TEXT, + created_at BIGINT NOT NULL, + UNIQUE (agent_id, seq_no) + ); + + CREATE INDEX IF NOT EXISTS idx_operations_org_created ON operations (org_id, created_at); + CREATE INDEX IF NOT EXISTS idx_operations_agent_seq ON operations (agent_id, seq_no); + CREATE INDEX IF NOT EXISTS idx_operations_type ON operations (operation_type); + + CREATE TABLE IF NOT EXISTS receipts ( + receipt_id TEXT NOT NULL PRIMARY KEY, + operation_id TEXT NOT NULL UNIQUE REFERENCES operations (operation_id), + r2_receipt_key TEXT NOT NULL, + created_at BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS epochs ( + epoch_id TEXT NOT NULL PRIMARY KEY, + org_id TEXT NOT NULL, + start_time BIGINT NOT NULL, + end_time BIGINT NOT NULL, + root_hash TEXT NOT NULL, + leaf_count BIGINT NOT NULL, + r2_epoch_key TEXT NOT NULL, + created_at BIGINT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS admin_events ( + event_id TEXT NOT NULL PRIMARY KEY, + org_id TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + details TEXT, + created_at BIGINT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_admin_events_org_created ON admin_events (org_id, created_at); + + CREATE TABLE IF NOT EXISTS exports ( + export_id TEXT NOT NULL PRIMARY KEY, + org_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'done', 'failed')), + query_params TEXT NOT NULL, + r2_export_key TEXT, + created_at BIGINT NOT NULL, + completed_at BIGINT + ); + + CREATE INDEX IF NOT EXISTS idx_exports_org_status ON exports (org_id, status); + + INSERT INTO schema_versions (version, applied_at, description) + VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema') + ON CONFLICT DO NOTHING; + " + restart: on-failure + + # ============================================================ + # API server + # ============================================================ + api: + build: + context: ./packages/server + dockerfile: Dockerfile + restart: unless-stopped + env_file: .env + ports: + - "${PORT:-8787}:8787" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + db-migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8787/v1/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + + # ============================================================ + # Worker (queue consumer) + # ============================================================ + worker: + build: + context: ./packages/server + dockerfile: Dockerfile + restart: unless-stopped + command: ["node", "dist/worker.js"] + env_file: .env + depends_on: + api: + condition: service_healthy + + # ============================================================ + # Scheduler (epoch scheduler) + # ============================================================ + scheduler: + build: + context: ./packages/server + dockerfile: Dockerfile + restart: unless-stopped + command: ["node", "dist/scheduler.js"] + env_file: .env + depends_on: + api: + condition: service_healthy + + # ============================================================ + # Console (Next.js frontend) + # ============================================================ + console: + build: + context: ./packages/console + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8787} + restart: unless-stopped + environment: + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8787} + ports: + - "3000:3000" + depends_on: + api: + condition: service_healthy + +volumes: + postgres_data: + redis_data: + minio_data: diff --git a/helm/elydora/Chart.yaml b/helm/elydora/Chart.yaml new file mode 100644 index 0000000..6697167 --- /dev/null +++ b/helm/elydora/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: elydora +description: Elydora — Open-source AI accountability and compliance platform +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/helm/elydora/templates/_helpers.tpl b/helm/elydora/templates/_helpers.tpl new file mode 100644 index 0000000..76a7797 --- /dev/null +++ b/helm/elydora/templates/_helpers.tpl @@ -0,0 +1,74 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "elydora.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "elydora.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label. +*/}} +{{- define "elydora.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "elydora.labels" -}} +helm.sh/chart: {{ include "elydora.chart" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "elydora.selectorLabels" -}} +app.kubernetes.io/name: {{ include "elydora.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Namespace. +*/}} +{{- define "elydora.namespace" -}} +{{- .Values.namespace | default "elydora" }} +{{- end }} + +{{/* +Database URL. +*/}} +{{- define "elydora.databaseUrl" -}} +postgresql://{{ .Values.postgres.user }}:{{ .Values.postgres.password }}@{{ .Values.postgres.host }}:{{ .Values.postgres.port }}/{{ .Values.postgres.database }} +{{- end }} + +{{/* +Redis URL. +*/}} +{{- define "elydora.redisUrl" -}} +redis://{{ .Values.redis.host }}:{{ .Values.redis.port }} +{{- end }} + +{{/* +MinIO endpoint. +*/}} +{{- define "elydora.minioEndpoint" -}} +http://{{ .Values.minio.host }}:{{ .Values.minio.port }} +{{- end }} diff --git a/helm/elydora/templates/api-deployment.yaml b/helm/elydora/templates/api-deployment.yaml new file mode 100644 index 0000000..44db414 --- /dev/null +++ b/helm/elydora/templates/api-deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: api +spec: + replicas: {{ .Values.replicaCount.api }} + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: "{{ .Values.image.api.repository }}:{{ .Values.image.api.tag }}" + imagePullPolicy: {{ .Values.image.api.pullPolicy }} + ports: + - containerPort: 8787 + envFrom: + - configMapRef: + name: elydora-config + - secretRef: + name: elydora-secret + resources: + {{- toYaml .Values.resources.api | nindent 12 }} + readinessProbe: + httpGet: + path: /health + port: 8787 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8787 + initialDelaySeconds: 20 + periodSeconds: 20 diff --git a/helm/elydora/templates/api-service.yaml b/helm/elydora/templates/api-service.yaml new file mode 100644 index 0000000..1a7ce53 --- /dev/null +++ b/helm/elydora/templates/api-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: api + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: api +spec: + ports: + - port: 8787 + targetPort: 8787 + selector: + app: api diff --git a/helm/elydora/templates/configmap.yaml b/helm/elydora/templates/configmap.yaml new file mode 100644 index 0000000..9b5b006 --- /dev/null +++ b/helm/elydora/templates/configmap.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: elydora-config + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} +data: + PORT: {{ .Values.api.port | quote }} + ENVIRONMENT: {{ .Values.api.environment | quote }} + REDIS_URL: {{ include "elydora.redisUrl" . | quote }} + MINIO_ENDPOINT: {{ include "elydora.minioEndpoint" . | quote }} + MINIO_BUCKET: {{ .Values.minio.bucket | quote }} + MINIO_ACCESS_KEY: {{ .Values.minio.rootUser | quote }} + ALLOWED_ORIGINS: {{ .Values.api.allowedOrigins | quote }} + TSA_URL: {{ .Values.api.tsaUrl | quote }} + NEXT_PUBLIC_API_BASE_URL: {{ .Values.console.apiBaseUrl | quote }} diff --git a/helm/elydora/templates/console-deployment.yaml b/helm/elydora/templates/console-deployment.yaml new file mode 100644 index 0000000..29bc763 --- /dev/null +++ b/helm/elydora/templates/console-deployment.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: console + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: console +spec: + replicas: {{ .Values.replicaCount.console }} + selector: + matchLabels: + app: console + template: + metadata: + labels: + app: console + spec: + containers: + - name: console + image: "{{ .Values.image.console.repository }}:{{ .Values.image.console.tag }}" + imagePullPolicy: {{ .Values.image.console.pullPolicy }} + ports: + - containerPort: 3000 + env: + - name: NEXT_PUBLIC_API_BASE_URL + valueFrom: + configMapKeyRef: + name: elydora-config + key: NEXT_PUBLIC_API_BASE_URL + resources: + {{- toYaml .Values.resources.console | nindent 12 }} + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 20 + periodSeconds: 20 diff --git a/helm/elydora/templates/console-service.yaml b/helm/elydora/templates/console-service.yaml new file mode 100644 index 0000000..d3b68e9 --- /dev/null +++ b/helm/elydora/templates/console-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: console + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: console +spec: + ports: + - port: 3000 + targetPort: 3000 + selector: + app: console diff --git a/helm/elydora/templates/ingress.yaml b/helm/elydora/templates/ingress.yaml new file mode 100644 index 0000000..ab7a61f --- /dev/null +++ b/helm/elydora/templates/ingress.yaml @@ -0,0 +1,51 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: elydora-ingress + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + rules: + - host: {{ .Values.ingress.api.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 8787 + - host: {{ .Values.ingress.console.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: console + port: + number: 3000 + {{- if or .Values.ingress.api.tls .Values.ingress.console.tls }} + tls: + {{- if .Values.ingress.api.tls }} + - hosts: + - {{ .Values.ingress.api.host }} + secretName: {{ .Values.ingress.api.tlsSecret }} + {{- end }} + {{- if .Values.ingress.console.tls }} + - hosts: + - {{ .Values.ingress.console.host }} + secretName: {{ .Values.ingress.console.tlsSecret }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/elydora/templates/job-migrate.yaml b/helm/elydora/templates/job-migrate.yaml new file mode 100644 index 0000000..283520b --- /dev/null +++ b/helm/elydora/templates/job-migrate.yaml @@ -0,0 +1,25 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: db-migrate + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: db-migrate + image: "{{ .Values.image.api.repository }}:{{ .Values.image.api.tag }}" + imagePullPolicy: {{ .Values.image.api.pullPolicy }} + command: ["node", "dist/migrate.js"] + envFrom: + - configMapRef: + name: elydora-config + - secretRef: + name: elydora-secret diff --git a/helm/elydora/templates/minio-service.yaml b/helm/elydora/templates/minio-service.yaml new file mode 100644 index 0000000..ddd8980 --- /dev/null +++ b/helm/elydora/templates/minio-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: minio +spec: + ports: + - port: 9000 + targetPort: 9000 + name: api + - port: 9001 + targetPort: 9001 + name: console + selector: + app: minio diff --git a/helm/elydora/templates/minio-statefulset.yaml b/helm/elydora/templates/minio-statefulset.yaml new file mode 100644 index 0000000..36b45d8 --- /dev/null +++ b/helm/elydora/templates/minio-statefulset.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: minio + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: minio +spec: + serviceName: minio + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + containers: + - name: minio + image: minio/minio:latest + command: ["server", "/data", "--console-address", ":9001"] + ports: + - containerPort: 9000 + name: api + - containerPort: 9001 + name: console + env: + - name: MINIO_ROOT_USER + value: {{ .Values.minio.rootUser | quote }} + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: elydora-secret + key: MINIO_ROOT_PASSWORD + volumeMounts: + - name: minio-data + mountPath: /data + readinessProbe: + httpGet: + path: /minio/health/ready + port: 9000 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /minio/health/live + port: 9000 + initialDelaySeconds: 20 + periodSeconds: 20 + volumeClaimTemplates: + - metadata: + name: minio-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.minio.storage }} diff --git a/helm/elydora/templates/namespace.yaml b/helm/elydora/templates/namespace.yaml new file mode 100644 index 0000000..6f60252 --- /dev/null +++ b/helm/elydora/templates/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} diff --git a/helm/elydora/templates/postgres-service.yaml b/helm/elydora/templates/postgres-service.yaml new file mode 100644 index 0000000..ae2124c --- /dev/null +++ b/helm/elydora/templates/postgres-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: postgres +spec: + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres + clusterIP: None diff --git a/helm/elydora/templates/postgres-statefulset.yaml b/helm/elydora/templates/postgres-statefulset.yaml new file mode 100644 index 0000000..26702ae --- /dev/null +++ b/helm/elydora/templates/postgres-statefulset.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: postgres +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + value: {{ .Values.postgres.user | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgres.database | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: elydora-secret + key: POSTGRES_PASSWORD + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.user }} + - -d + - {{ .Values.postgres.database }} + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: + - pg_isready + - -U + - {{ .Values.postgres.user }} + - -d + - {{ .Values.postgres.database }} + initialDelaySeconds: 15 + periodSeconds: 10 + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.postgres.storage }} diff --git a/helm/elydora/templates/redis-service.yaml b/helm/elydora/templates/redis-service.yaml new file mode 100644 index 0000000..c40e0c0 --- /dev/null +++ b/helm/elydora/templates/redis-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: redis +spec: + ports: + - port: 6379 + targetPort: 6379 + selector: + app: redis + clusterIP: None diff --git a/helm/elydora/templates/redis-statefulset.yaml b/helm/elydora/templates/redis-statefulset.yaml new file mode 100644 index 0000000..880e8e6 --- /dev/null +++ b/helm/elydora/templates/redis-statefulset.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: redis +spec: + serviceName: redis + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + ports: + - containerPort: 6379 + volumeMounts: + - name: redis-data + mountPath: /data + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 10 + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.redis.storage }} diff --git a/helm/elydora/templates/scheduler-deployment.yaml b/helm/elydora/templates/scheduler-deployment.yaml new file mode 100644 index 0000000..327e392 --- /dev/null +++ b/helm/elydora/templates/scheduler-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scheduler + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: scheduler +spec: + replicas: {{ .Values.replicaCount.scheduler }} + selector: + matchLabels: + app: scheduler + template: + metadata: + labels: + app: scheduler + spec: + containers: + - name: scheduler + image: "{{ .Values.image.api.repository }}:{{ .Values.image.api.tag }}" + imagePullPolicy: {{ .Values.image.api.pullPolicy }} + command: ["node", "dist/scheduler.js"] + envFrom: + - configMapRef: + name: elydora-config + - secretRef: + name: elydora-secret + resources: + {{- toYaml .Values.resources.scheduler | nindent 12 }} diff --git a/helm/elydora/templates/secret.yaml b/helm/elydora/templates/secret.yaml new file mode 100644 index 0000000..0c8334c --- /dev/null +++ b/helm/elydora/templates/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: elydora-secret + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} +type: Opaque +stringData: + DATABASE_URL: {{ include "elydora.databaseUrl" . | quote }} + JWT_SECRET: {{ .Values.api.jwtSecret | quote }} + ELYDORA_SIGNING_KEY: {{ .Values.api.signingKey | quote }} + POSTGRES_PASSWORD: {{ .Values.postgres.password | quote }} + MINIO_SECRET_KEY: {{ .Values.minio.rootPassword | quote }} + MINIO_ROOT_PASSWORD: {{ .Values.minio.rootPassword | quote }} diff --git a/helm/elydora/templates/worker-deployment.yaml b/helm/elydora/templates/worker-deployment.yaml new file mode 100644 index 0000000..b705b70 --- /dev/null +++ b/helm/elydora/templates/worker-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: worker + namespace: {{ include "elydora.namespace" . }} + labels: + {{- include "elydora.labels" . | nindent 4 }} + app: worker +spec: + replicas: {{ .Values.replicaCount.worker }} + selector: + matchLabels: + app: worker + template: + metadata: + labels: + app: worker + spec: + containers: + - name: worker + image: "{{ .Values.image.api.repository }}:{{ .Values.image.api.tag }}" + imagePullPolicy: {{ .Values.image.api.pullPolicy }} + command: ["node", "dist/worker.js"] + envFrom: + - configMapRef: + name: elydora-config + - secretRef: + name: elydora-secret + resources: + {{- toYaml .Values.resources.worker | nindent 12 }} diff --git a/helm/elydora/values.yaml b/helm/elydora/values.yaml new file mode 100644 index 0000000..cff41a2 --- /dev/null +++ b/helm/elydora/values.yaml @@ -0,0 +1,102 @@ +global: + imageRegistry: "" + +image: + api: + repository: elydora/api + tag: latest + pullPolicy: IfNotPresent + console: + repository: elydora/console + tag: latest + pullPolicy: IfNotPresent + +namespace: elydora + +replicaCount: + api: 2 + worker: 2 + scheduler: 1 + console: 2 + +resources: + api: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + worker: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + scheduler: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + console: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + +# PostgreSQL +postgres: + host: postgres + port: 5432 + user: elydora + password: elydora-secret-change-me + database: elydora + storage: 10Gi + +# Redis +redis: + host: redis + port: 6379 + storage: 2Gi + +# MinIO +minio: + host: minio + port: 9000 + rootUser: elydora + rootPassword: elydora-minio-secret-change-me + bucket: elydora-evidence + storage: 50Gi + +# Elydora API config +api: + port: 8787 + environment: production + jwtSecret: change-me-to-a-random-string-at-least-32-chars + signingKey: change-me-base64url-ed25519-private-key + allowedOrigins: "https://console.example.com" + tsaUrl: "http://timestamp.sectigo.com" + +# Console +console: + port: 3000 + apiBaseUrl: "https://api.example.com" + +# Ingress +ingress: + enabled: true + className: nginx + annotations: {} + api: + host: api.example.com + tls: true + tlsSecret: elydora-api-tls + console: + host: console.example.com + tls: true + tlsSecret: elydora-console-tls diff --git a/packages/console/.dockerignore b/packages/console/.dockerignore new file mode 100644 index 0000000..1b11b0c --- /dev/null +++ b/packages/console/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +out +.git +*.md diff --git a/packages/console/.gitignore b/packages/console/.gitignore new file mode 100644 index 0000000..dd01962 --- /dev/null +++ b/packages/console/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +dist/ +.next/ +out/ +.wrangler/ +.open-next/ +.dev.vars +.env +.env.local +.env.production +*.log +.DS_Store +Thumbs.db +.turbo/ +coverage/ +*.tsbuildinfo +next-env.d.ts diff --git a/packages/console/Dockerfile b/packages/console/Dockerfile new file mode 100644 index 0000000..d0ba923 --- /dev/null +++ b/packages/console/Dockerfile @@ -0,0 +1,25 @@ +# Multi-stage build +# Stage 1: Install deps and build +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8787 +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +RUN npm run build + +# Stage 2: Production +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server.js"] diff --git a/packages/console/README.md b/packages/console/README.md new file mode 100644 index 0000000..eafdbd3 --- /dev/null +++ b/packages/console/README.md @@ -0,0 +1,124 @@ +# Elydora Console + +Web dashboard for the Elydora Responsibility Protocol. Provides agent management, operation inspection, epoch visualization, audit log querying, and compliance export tools. + +## Tech Stack + +- **Framework:** Next.js 16.1 (App Router) +- **Language:** TypeScript 5.7 +- **UI:** React 19, Tailwind CSS 4 +- **Data Fetching:** SWR 2.3 +- **Deployment:** Cloudflare Pages (static export) + +## Project Structure + +``` +src/ +├── app/ +│ ├── layout.tsx # Root layout with AppShell +│ ├── page.tsx # Dashboard — stats, recent operations, quick actions +│ ├── globals.css # Theme variables, global styles +│ ├── agents/ +│ │ ├── page.tsx # Agent list, search, registration +│ │ └── [agent_id]/page.tsx # Agent details, keys, freeze/unfreeze +│ ├── operations/ +│ │ ├── page.tsx # Operations log with filtering +│ │ └── [operation_id]/page.tsx # Operation details and verification +│ ├── epochs/ +│ │ ├── page.tsx # Epoch list with root hashes +│ │ └── [epoch_id]/page.tsx # Epoch details, Merkle proof visualization +│ ├── exports/ +│ │ └── page.tsx # Create and download compliance exports +│ ├── audit/ +│ │ └── page.tsx # Audit log query builder +│ ├── login/page.tsx # Login form +│ ├── register/page.tsx # Registration form +│ └── jwks/page.tsx # Public key display +├── components/ +│ ├── AppShell.tsx # Main layout wrapper with sidebar +│ ├── AgentDetailClient.tsx # Agent info and key management +│ ├── AgentRegistrationForm.tsx # Register new agent form +│ ├── OperationDetailClient.tsx # Operation inspection and verification +│ ├── OperationDetailCard.tsx # Operation data display +│ ├── EpochDetailClient.tsx # Epoch proof and verification +│ ├── ChainVisualization.tsx # Merkle chain visualization +│ ├── VerificationChecklist.tsx # Proof verification UI +│ └── ui/ +│ ├── PageHeader.tsx # Page title and breadcrumbs +│ ├── DataTable.tsx # Sortable, paginated table +│ ├── Modal.tsx # Dialog component +│ ├── SearchInput.tsx # Search/filter input +│ ├── StatusBadge.tsx # Status indicator +│ ├── CopyButton.tsx # Copy-to-clipboard +│ └── Sidebar.tsx # Navigation sidebar +├── lib/ +│ ├── api.ts # Typed REST client for all endpoints +│ ├── auth.tsx # Auth context and hooks +│ └── hooks.ts # SWR data-fetching hooks +└── shared/ # Shared types (@elydora/shared) +``` + +## Getting Started + +### Prerequisites + +- Node.js 18+ + +### Installation + +```bash +npm install +``` + +### Environment + +| Variable | Purpose | Default | +|----------|---------|---------| +| `NEXT_PUBLIC_API_BASE_URL` | Backend API URL | `http://localhost:8787` | + +### Development + +```bash +npm run dev +``` + +Runs at `http://localhost:3000` with Turbopack. + +### Build + +```bash +npm run build +``` + +Produces a static export in `out/`. A post-build script removes stale `_redirects` files. + +### Deployment + +```bash +npm run deploy +``` + +Deploys the `out/` directory to Cloudflare Pages. + +### Type Checking + +```bash +npm run typecheck +``` + +## Pages + +| Path | Description | +|------|-------------| +| `/` | Dashboard with stats, recent operations, and quick actions | +| `/agents` | List, search, and register agents | +| `/agents/[agent_id]` | Agent details, key management, freeze/unfreeze | +| `/operations` | Browse operations with agent and type filters | +| `/operations/[operation_id]` | Operation details, chain hash verification | +| `/epochs` | List epochs with root hashes and leaf counts | +| `/epochs/[epoch_id]` | Epoch details with Merkle proof visualization | +| `/exports` | Create and download compliance exports (JSON/PDF) | +| `/audit` | Query builder for audit log with time range and agent filters | +| `/login` | Login | +| `/register` | Register account and organization | +| `/jwks` | Display server public keys in JWK format | diff --git a/packages/console/next.config.ts b/packages/console/next.config.ts new file mode 100644 index 0000000..225e495 --- /dev/null +++ b/packages/console/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: 'standalone', +}; + +export default nextConfig; diff --git a/packages/console/package-lock.json b/packages/console/package-lock.json new file mode 100644 index 0000000..1c515dd --- /dev/null +++ b/packages/console/package-lock.json @@ -0,0 +1,1794 @@ +{ + "name": "elydora-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elydora-console", + "version": "0.1.0", + "dependencies": { + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "next": "^16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-i18next": "^16.5.4", + "swr": "^2.3.0" + }, + "devDependencies": { + "@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", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "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==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@types/node": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", + "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz", + "integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "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" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "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/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/packages/console/package.json b/packages/console/package.json new file mode 100644 index 0000000..90114f7 --- /dev/null +++ b/packages/console/package.json @@ -0,0 +1,29 @@ +{ + "name": "elydora-console", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "next": "^16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-i18next": "^16.5.4", + "swr": "^2.3.0" + }, + "devDependencies": { + "@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", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/console/postcss.config.mjs b/packages/console/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/packages/console/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/packages/console/public/favicon.svg b/packages/console/public/favicon.svg new file mode 100644 index 0000000..496f7d6 --- /dev/null +++ b/packages/console/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/console/src/app/agents/[agent_id]/client.tsx b/packages/console/src/app/agents/[agent_id]/client.tsx new file mode 100644 index 0000000..04cb145 --- /dev/null +++ b/packages/console/src/app/agents/[agent_id]/client.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import AgentDetailClient from '@/components/AgentDetailClient'; + +export default function AgentDetailShell() { + const [agentId, setAgentId] = useState(''); + useEffect(() => { + setAgentId(window.location.pathname.split('/')[2] ?? ''); + }, []); + if (!agentId) return null; + return ; +} diff --git a/packages/console/src/app/agents/[agent_id]/page.tsx b/packages/console/src/app/agents/[agent_id]/page.tsx new file mode 100644 index 0000000..fcd374b --- /dev/null +++ b/packages/console/src/app/agents/[agent_id]/page.tsx @@ -0,0 +1,9 @@ +import AgentDetailShell from './client'; + +export async function generateStaticParams() { + return [{ agent_id: 'detail' }]; +} + +export default function AgentDetailPage() { + return ; +} diff --git a/packages/console/src/app/agents/page.tsx b/packages/console/src/app/agents/page.tsx new file mode 100644 index 0000000..6e378c2 --- /dev/null +++ b/packages/console/src/app/agents/page.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { Suspense, useState, useCallback, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslation } from 'react-i18next'; +import { useAgentsList, useAudit, formatRelativeTime } from '@/lib/hooks'; +import { api } from '@/lib/api'; +import PageHeader from '@/components/ui/PageHeader'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +import SearchInput from '@/components/ui/SearchInput'; +import Modal from '@/components/ui/Modal'; +import AgentRegistrationForm from '@/components/AgentRegistrationForm'; + +interface AgentRow { + agent_id: string; + display_name: string; + status: string; + created_at: number; + [key: string]: unknown; +} + +/** Fetches the real total_count for a single agent via the audit API. */ +function AgentOpCount({ agentId }: { agentId: string }) { + const { data, isLoading } = useAudit({ agent_id: agentId }); + if (isLoading) return —; + return {data?.total_count ?? 0}; +} + +/** Fetches the latest operation timestamp for a single agent via the audit API. */ +function AgentLatestActivity({ agentId, fallback }: { agentId: string; fallback: number }) { + const { data, isLoading } = useAudit({ agent_id: agentId }); + if (isLoading) return —; + const latest = data?.operations?.[0]?.issued_at ?? fallback; + return {formatRelativeTime(latest)}; +} + +function AgentsContent() { + const { t } = useTranslation(); + const router = useRouter(); + const [search, setSearch] = useState(''); + const [showRegister, setShowRegister] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const { data: agentsData, isLoading: agentsLoading, error: agentsError, mutate } = useAgentsList(); + + const agents: AgentRow[] = useMemo(() => { + if (!agentsData?.agents) return []; + return agentsData.agents.map((agent) => ({ + agent_id: agent.agent_id, + display_name: agent.display_name, + status: agent.status, + created_at: agent.created_at, + })); + }, [agentsData]); + + const filteredAgents = useMemo(() => { + if (!search.trim()) return agents; + const q = search.toLowerCase(); + return agents.filter( + (a) => a.agent_id.toLowerCase().includes(q) || a.display_name.toLowerCase().includes(q), + ); + }, [agents, search]); + + const columns: Column[] = [ + { + key: 'agent_id', + label: t('agents.colAgent'), + sortable: true, + render: (row) => ( +
+ {row.agent_id} + {row.display_name && row.display_name !== row.agent_id && ( + {row.display_name} + )} +
+ ), + }, + { + key: 'status', + label: t('agents.colStatus'), + sortable: true, + width: '100px', + render: (row) => ( + + {row.status} + + ), + }, + { + key: 'operation_count', + label: t('agents.colOperations'), + width: '120px', + render: (row) => , + }, + { + key: 'latest_activity', + label: t('agents.colLatestActivity'), + render: (row) => , + }, + { + key: 'actions', + label: '', + width: '80px', + render: (row) => ( + + ), + }, + ]; + + const handleRowClick = useCallback( + (row: AgentRow) => { + router.push(`/agents/${row.agent_id}`); + }, + [router], + ); + + const handleRegisterSuccess = useCallback(() => { + setShowRegister(false); + mutate(); + }, [mutate]); + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return; + setDeleting(true); + setDeleteError(null); + try { + await api.agents.delete(deleteTarget.agent_id); + setDeleteTarget(null); + mutate(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : t('agents.failedToDelete')); + } finally { + setDeleting(false); + } + }, [deleteTarget, mutate]); + + return ( +
+ setShowRegister(true)}> + {t('agents.registerAgent')} + + } + /> + + {agentsError && ( +
+ {t('agents.failedToLoad')} {agentsError instanceof Error ? agentsError.message : t('common.unknownError')} +
+ )} + +
+ +
+ + row.agent_id} + onRowClick={handleRowClick} + isLoading={agentsLoading} + emptyMessage={t('agents.emptyMessage')} + /> + + setShowRegister(false)} + title={t('agents.registerAgent')} + width="max-w-2xl" + > + setShowRegister(false)} + /> + + + { setDeleteTarget(null); setDeleteError(null); }} + title={t('agents.deleteAgent')} + > +
+

+ {t('agents.deleteConfirmPre')}{deleteTarget?.agent_id}{t('agents.deleteConfirmPost')} +

+ {deleteError && ( +
+ {deleteError} +
+ )} +
+ + +
+
+
+
+ ); +} + +export default function AgentsPage() { + return ( +
}> + + + ); +} diff --git a/packages/console/src/app/audit/page.tsx b/packages/console/src/app/audit/page.tsx new file mode 100644 index 0000000..96c1294 --- /dev/null +++ b/packages/console/src/app/audit/page.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslation } from 'react-i18next'; +import { useAudit, formatTimestamp, formatRelativeTime } from '@/lib/hooks'; +import PageHeader from '@/components/ui/PageHeader'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +import type { Operation, AuditQueryRequest } from '@elydora/shared'; +import Link from 'next/link'; + +export default function AuditPage() { + const { t } = useTranslation(); + const router = useRouter(); + const [agentId, setAgentId] = useState(''); + const [operationType, setOperationType] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [cursor, setCursor] = useState(undefined); + const [limit] = useState(50); + + const queryParams: AuditQueryRequest = { + agent_id: agentId || undefined, + operation_type: operationType || undefined, + start_time: startTime ? new Date(startTime).getTime() : undefined, + end_time: endTime ? new Date(endTime).getTime() : undefined, + cursor, + limit, + }; + + const { data, isLoading, error } = useAudit(queryParams); + const operations = data?.operations ?? []; + + const columns: Column>[] = [ + { + key: 'operation_id', + label: t('audit.colOperationId'), + render: (row) => ( + + {row.operation_id.slice(0, 18)}... + + ), + }, + { + key: 'agent_id', + label: t('audit.colAgent'), + sortable: true, + render: (row) => ( + + {row.agent_id} + + ), + }, + { + key: 'operation_type', + label: t('audit.colType'), + sortable: true, + render: (row) => ( + + {row.operation_type} + + ), + }, + { + key: 'issued_at', + label: t('audit.colIssuedAt'), + sortable: true, + render: (row) => ( + + {formatRelativeTime(row.issued_at)} + + ), + }, + { + key: 'seq_no', + label: t('audit.colSeq'), + sortable: true, + width: '80px', + render: (row) => ( + #{row.seq_no} + ), + }, + { + key: 'chain_hash', + label: t('audit.colChainHash'), + render: (row) => ( + + {row.chain_hash.slice(0, 12)}... + + ), + }, + ]; + + const handleClearFilters = useCallback(() => { + setAgentId(''); + setOperationType(''); + setStartTime(''); + setEndTime(''); + setCursor(undefined); + }, []); + + return ( +
+ + {t('audit.exportData')} + + } + /> + + {error && ( +
+ {t('audit.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')} +
+ )} + + {/* Query Builder */} +
+
+

{t('audit.queryBuilder')}

+ +
+ +
+
+ + { + setAgentId(e.target.value); + setCursor(undefined); + }} + placeholder={t('audit.filterByAgent')} + 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" + /> +
+ +
+ + { + setOperationType(e.target.value); + setCursor(undefined); + }} + placeholder={t('audit.filterByType')} + 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" + /> +
+ +
+ + { + setStartTime(e.target.value); + setCursor(undefined); + }} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors" + /> +
+ +
+ + { + setEndTime(e.target.value); + setCursor(undefined); + }} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors" + /> +
+
+ + {data && ( +
+ + {t('audit.showing', { shown: operations.length, total: data.total_count })} + + {data.cursor && ( + + {t('audit.moreResults')} + + )} +
+ )} +
+ + {/* Results Table */} + )[]} + keyExtractor={(row) => row.operation_id as string} + onRowClick={(row) => { router.push(`/operations/${row.operation_id}`); }} + isLoading={isLoading} + emptyMessage={t('audit.emptyMessage')} + pagination={ + data?.cursor + ? { + cursor: data.cursor, + hasMore: true, + onLoadMore: () => setCursor(data.cursor), + } + : undefined + } + /> +
+ ); +} diff --git a/packages/console/src/app/epochs/[epoch_id]/client.tsx b/packages/console/src/app/epochs/[epoch_id]/client.tsx new file mode 100644 index 0000000..065f30d --- /dev/null +++ b/packages/console/src/app/epochs/[epoch_id]/client.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import EpochDetailClient from '@/components/EpochDetailClient'; + +export default function EpochDetailShell() { + const [epochId, setEpochId] = useState(''); + useEffect(() => { + setEpochId(window.location.pathname.split('/')[2] ?? ''); + }, []); + if (!epochId) return null; + return ; +} diff --git a/packages/console/src/app/epochs/[epoch_id]/page.tsx b/packages/console/src/app/epochs/[epoch_id]/page.tsx new file mode 100644 index 0000000..c014d36 --- /dev/null +++ b/packages/console/src/app/epochs/[epoch_id]/page.tsx @@ -0,0 +1,9 @@ +import EpochDetailShell from './client'; + +export async function generateStaticParams() { + return [{ epoch_id: 'detail' }]; +} + +export default function EpochDetailPage() { + return ; +} diff --git a/packages/console/src/app/epochs/page.tsx b/packages/console/src/app/epochs/page.tsx new file mode 100644 index 0000000..c6f8b8c --- /dev/null +++ b/packages/console/src/app/epochs/page.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Suspense, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslation } from 'react-i18next'; +import { useEpochs, formatTimestamp, truncateHash } from '@/lib/hooks'; +import PageHeader from '@/components/ui/PageHeader'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +import CopyButton from '@/components/ui/CopyButton'; + +interface EpochRow { + epoch_id: string; + org_id: string; + start_time: number; + end_time: number; + root_hash: string; + leaf_count: number; + r2_epoch_key: string; + created_at: number; + [key: string]: unknown; +} + +function EpochsContent() { + const { t } = useTranslation(); + const router = useRouter(); + const { data, isLoading, error } = useEpochs(); + const epochs = (data?.epochs ?? []) as EpochRow[]; + + const columns: Column[] = [ + { + key: 'epoch_id', + label: t('epochs.colEpochId'), + render: (row) => ( + + {row.epoch_id.slice(0, 16)}... + + ), + }, + { + key: 'start_time', + label: t('epochs.colTimeRange'), + sortable: true, + render: (row) => ( +
+
{formatTimestamp(row.start_time)}
+
to {formatTimestamp(row.end_time)}
+
+ ), + }, + { + key: 'leaf_count', + label: t('epochs.colLeaves'), + sortable: true, + width: '100px', + render: (row) => ( + {row.leaf_count} + ), + }, + { + key: 'root_hash', + label: t('epochs.colRootHash'), + render: (row) => ( +
+ + {truncateHash(row.root_hash, 10)} + + +
+ ), + }, + { + key: 'created_at', + label: t('epochs.colCreated'), + sortable: true, + render: (row) => ( + + {formatTimestamp(row.created_at)} + + ), + }, + ]; + + const handleRowClick = useCallback( + (row: EpochRow) => { + router.push(`/epochs/${row.epoch_id}`); + }, + [router], + ); + + return ( +
+ + + {error && ( +
+ {t('epochs.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')} +
+ )} + + row.epoch_id} + onRowClick={handleRowClick} + isLoading={isLoading} + emptyMessage={t('epochs.emptyMessage')} + /> +
+ ); +} + +export default function EpochsPage() { + return ( +
}> + + + ); +} diff --git a/packages/console/src/app/exports/page.tsx b/packages/console/src/app/exports/page.tsx new file mode 100644 index 0000000..6efe0e2 --- /dev/null +++ b/packages/console/src/app/exports/page.tsx @@ -0,0 +1,296 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useExports, formatTimestamp } from '@/lib/hooks'; +import { api } from '@/lib/api'; +import PageHeader from '@/components/ui/PageHeader'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +import StatusBadge from '@/components/ui/StatusBadge'; +import Modal from '@/components/ui/Modal'; +import type { Export, CreateExportRequest, ExportStatus } from '@elydora/shared'; + +interface ExportRow extends Export { + [key: string]: unknown; +} + +export default function ExportsPage() { + const { t } = useTranslation(); + const { data, isLoading, error, mutate } = useExports(); + const exports = (data?.exports ?? []) as ExportRow[]; + + const [showCreate, setShowCreate] = useState(false); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [agentId, setAgentId] = useState(''); + const [operationType, setOperationType] = useState(''); + const [format, setFormat] = useState<'json' | 'pdf'>('json'); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const handleCreate = useCallback(async () => { + if (!startTime || !endTime) { + setCreateError(t('exports.startEndRequired')); + return; + } + + setCreating(true); + setCreateError(null); + try { + const body: CreateExportRequest = { + start_time: new Date(startTime).getTime(), + end_time: new Date(endTime).getTime(), + agent_id: agentId || undefined, + operation_type: operationType || undefined, + format, + }; + await api.exports.create(body); + setShowCreate(false); + setStartTime(''); + setEndTime(''); + setAgentId(''); + setOperationType(''); + mutate(); + } catch (err) { + setCreateError(err instanceof Error ? err.message : t('exports.failedToCreate')); + } finally { + setCreating(false); + } + }, [startTime, endTime, agentId, operationType, format, mutate]); + + const handleDownload = useCallback(async (exportId: string, queryParams: string) => { + try { + let ext = ''; + try { + const params = JSON.parse(queryParams); + ext = params.format === 'pdf' ? '.pdf' : '.json'; + } catch { ext = ''; } + const blob = await api.exports.download(exportId); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `export-${exportId}${ext}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + // Download error is non-critical + } + }, []); + + const columns: Column[] = [ + { + key: 'export_id', + label: t('exports.colExportId'), + render: (row) => ( + + {row.export_id.slice(0, 16)}... + + ), + }, + { + key: 'status', + label: t('exports.colStatus'), + width: '120px', + render: (row) => , + }, + { + key: 'query_params', + label: t('exports.colFilters'), + render: (row) => { + let params: Record = {}; + try { + params = JSON.parse(row.query_params); + } catch { + // Not valid JSON + } + const entries = Object.entries(params).filter(([, v]) => v != null); + if (entries.length === 0) { + return {t('common.allData')}; + } + return ( + + {entries.map(([k, v]) => `${k}=${v}`).join(', ')} + + ); + }, + }, + { + key: 'created_at', + label: t('exports.colCreated'), + sortable: true, + render: (row) => ( + + {formatTimestamp(row.created_at)} + + ), + }, + { + key: 'completed_at', + label: t('exports.colCompleted'), + render: (row) => ( + + {row.completed_at ? formatTimestamp(row.completed_at) : '\u2014'} + + ), + }, + { + key: 'actions', + label: '', + width: '100px', + render: (row) => + row.status === 'done' ? ( + + ) : null, + }, + ]; + + return ( +
+ setShowCreate(true)}> + {t('exports.createExport')} + + } + /> + + {error && ( +
+ {t('exports.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')} +
+ )} + + row.export_id} + isLoading={isLoading} + emptyMessage={t('exports.emptyMessage')} + /> + + {/* Create Export Modal */} + { + setShowCreate(false); + setCreateError(null); + }} + title={t('exports.createExport')} + width="max-w-xl" + > +
+ {createError && ( +
+ {createError} +
+ )} + +
+
+ + setStartTime(e.target.value)} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors" + required + /> +
+
+ + setEndTime(e.target.value)} + className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors" + required + /> +
+
+ +
+ + setAgentId(e.target.value)} + placeholder={t('exports.filterByAgent')} + 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" + /> +
+ +
+ + setOperationType(e.target.value)} + placeholder={t('exports.filterByOperationType')} + 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" + /> +
+ +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/packages/console/src/app/global-error.tsx b/packages/console/src/app/global-error.tsx new file mode 100644 index 0000000..22602c8 --- /dev/null +++ b/packages/console/src/app/global-error.tsx @@ -0,0 +1,19 @@ +'use client'; + +export default function GlobalError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+

Something went wrong

+ +
+ + + ); +} diff --git a/packages/console/src/app/globals.css b/packages/console/src/app/globals.css new file mode 100644 index 0000000..ff37fc5 --- /dev/null +++ b/packages/console/src/app/globals.css @@ -0,0 +1,355 @@ +@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500;600;700;800&display=swap'); + +@import "tailwindcss"; + +:root { + --bg-color: #EAEAE5; + --surface-color: #F2F2EE; + --ink-color: #121212; + --ink-dim: #666666; + --border-color: #C0C0BA; + --grid-line: rgba(18, 18, 18, 0.08); + --white: #FFFFFF; + --sidebar-width: 260px; +} + +@theme inline { + --color-bg: var(--bg-color); + --color-surface: var(--surface-color); + --color-ink: var(--ink-color); + --color-ink-dim: var(--ink-dim); + --color-border: var(--border-color); + --color-grid-line: var(--grid-line); + --font-sans: 'Manrope', 'Noto Sans SC', sans-serif; + --font-mono: 'JetBrains Mono', 'Noto Sans SC', monospace; +} + +* { + box-sizing: border-box; +} + +body { + background-color: var(--bg-color); + color: var(--ink-color); + font-family: 'Manrope', sans-serif; + margin: 0; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +:lang(zh) body, +:lang(zh-CN) body, +:lang(zh-TW) body { + font-family: 'Noto Sans SC', 'Manrope', sans-serif; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg-color); +} +::-webkit-scrollbar-thumb { + background: var(--ink-color); +} + +/* Grid background */ +.grid-bg { + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 40px 40px; +} + +/* Crosshair markers */ +.crosshair { + position: absolute; + width: 11px; + height: 11px; + z-index: 10; + pointer-events: none; +} +.crosshair::before, .crosshair::after { + content: ''; + position: absolute; + background-color: var(--ink-color); +} +.crosshair::before { width: 1px; height: 100%; left: 5px; } +.crosshair::after { height: 1px; width: 100%; top: 5px; } + +.ch-tl { top: -12px; left: -12px; } +.ch-tr { top: -12px; right: -12px; } +.ch-bl { bottom: -12px; left: -12px; } +.ch-br { bottom: -12px; right: -12px; } + +@media (max-width: 640px) { + .crosshair { display: none; } +} + +/* Display text */ +.display-text { + font-size: clamp(2.5rem, 5vw, 4.5rem); + line-height: 1.1; + letter-spacing: -0.04em; +} + +/* Console display - smaller variant */ +.console-display { + font-size: clamp(1.5rem, 3vw, 2.5rem); + line-height: 1.1; + letter-spacing: -0.03em; +} + +/* Navigation item */ +.nav-item { + position: relative; + padding-left: 1rem; + transition: all 0.3s ease; +} +.nav-item::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 4px; + height: 4px; + background: var(--ink-color); + transform: translateY(-50%) scale(0); + transition: transform 0.2s ease; +} +.nav-item:hover { + padding-left: 1.5rem; + color: var(--ink-color); +} +.nav-item:hover::before { + transform: translateY(-50%) scale(1); +} + +/* Sidebar navigation item */ +.sidebar-nav-item { + position: relative; + padding: 8px 16px; + font-family: 'JetBrains Mono', 'Noto Sans SC', monospace; + font-size: 12px; + font-weight: 400; + letter-spacing: 0.02em; + color: rgba(234, 234, 229, 0.6); + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + text-decoration: none; +} +.sidebar-nav-item:hover { + color: #EAEAE5; + background: rgba(234, 234, 229, 0.05); +} +.sidebar-nav-item.active { + color: #EAEAE5; + background: rgba(234, 234, 229, 0.08); +} +.sidebar-nav-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 2px; + background: #EAEAE5; +} + +/* Marquee */ +.marquee-container { + overflow: hidden; + white-space: nowrap; +} +.marquee-content { + display: inline-block; + animation: marquee 40s linear infinite; +} +@keyframes marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +/* Brutalist button */ +.btn-brutalist { + background: var(--ink-color); + color: var(--bg-color); + border: 1px solid var(--ink-color); + transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1); + position: relative; + overflow: hidden; + cursor: pointer; + font-family: 'JetBrains Mono', 'Noto Sans SC', monospace; + font-weight: 700; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 10px 24px; +} +.btn-brutalist:hover { + background: transparent; + color: var(--ink-color); +} +.btn-brutalist:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.btn-brutalist:disabled:hover { + background: var(--ink-color); + color: var(--bg-color); +} + +/* Ghost button */ +.btn-ghost { + background: transparent; + color: var(--ink-color); + border: 1px solid var(--border-color); + transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1); + cursor: pointer; + font-family: 'JetBrains Mono', 'Noto Sans SC', monospace; + font-weight: 700; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 10px 24px; +} +.btn-ghost:hover { + background: var(--bg-color); + border-color: var(--ink-color); +} +.btn-ghost:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Data row */ +.data-row { + transition: background-color 0.2s ease; +} +.data-row:hover { + background-color: #FFFFFF; +} + +/* Feature card */ +.feature-card { + transition: all 0.3s ease; +} +.feature-card:hover { + background-color: #FFFFFF; +} + +/* Stat card */ +.stat-card { + background: var(--surface-color); + border: 1px solid var(--border-color); + padding: 24px; + position: relative; + transition: all 0.3s ease; +} +.stat-card:hover { + background: #FFFFFF; +} + +/* Loading bar animation */ +@keyframes load { + 0% { transform: scaleX(0); } + 50% { transform: scaleX(0.7); } + 100% { transform: scaleX(0); transform-origin: right; } +} + +/* Fade in animation */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +.fade-in { + animation: fadeIn 0.6s ease forwards; +} + +/* Stagger children */ +.stagger-children > * { + opacity: 0; + animation: fadeIn 0.5s ease forwards; +} +.stagger-children > *:nth-child(1) { animation-delay: 0.1s; } +.stagger-children > *:nth-child(2) { animation-delay: 0.2s; } +.stagger-children > *:nth-child(3) { animation-delay: 0.3s; } +.stagger-children > *:nth-child(4) { animation-delay: 0.4s; } +.stagger-children > *:nth-child(5) { animation-delay: 0.5s; } +.stagger-children > *:nth-child(6) { animation-delay: 0.6s; } + +/* Skeleton loading */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +.skeleton { + background: linear-gradient(90deg, var(--border-color) 25%, var(--surface-color) 50%, var(--border-color) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: 2px; +} + +/* Pulse dot animation */ +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.pulse-dot { + animation: pulse-dot 2s ease-in-out infinite; +} + +/* Modal overlay */ +.modal-overlay { + background: transparent; +} + +/* Section label */ +.section-label { + font-family: 'JetBrains Mono', 'Noto Sans SC', monospace; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--ink-dim); +} + +/* Table header */ +.table-header { + font-family: 'JetBrains Mono', 'Noto Sans SC', monospace; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-dim); +} + +/* Monospace data */ +.mono-data { + font-family: 'JetBrains Mono', 'Noto Sans SC', monospace; + font-size: 13px; + font-weight: 400; + overflow-wrap: break-word; + word-break: break-all; +} + +/* Mobile-friendly table cells */ +@media (max-width: 640px) { + .stat-card { + padding: 16px; + } +} + +/* Checkbox/check icon */ +.check-pass { + color: #16a34a; +} +.check-fail { + color: #dc2626; +} diff --git a/packages/console/src/app/jwks/page.tsx b/packages/console/src/app/jwks/page.tsx new file mode 100644 index 0000000..4d6f358 --- /dev/null +++ b/packages/console/src/app/jwks/page.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useJWKS } from '@/lib/hooks'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/ui/PageHeader'; +import CopyButton from '@/components/ui/CopyButton'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +import type { JWK } from '@elydora/shared'; + +interface JWKRow extends JWK { + [key: string]: unknown; +} + +export default function JWKSPage() { + const { t } = useTranslation(); + const { data: jwksData, isLoading: jwksLoading, error: jwksError } = useJWKS(); + const keys = (jwksData?.keys ?? []) as JWKRow[]; + + const jwkColumns: Column[] = [ + { + key: 'kid', + label: t('jwks.colKeyId'), + render: (row) => ( +
+ {row.kid} + +
+ ), + }, + { + key: 'kty', + label: t('jwks.colKeyType'), + width: '100px', + render: (row) => ( + + {row.kty} + + ), + }, + { + key: 'alg', + label: t('jwks.colAlgorithm'), + width: '120px', + render: (row) => ( + {row.alg} + ), + }, + { + key: 'crv', + label: t('jwks.colCurve'), + width: '100px', + render: (row) => ( + {row.crv ?? '\u2014'} + ), + }, + { + key: 'use', + label: t('jwks.colUse'), + width: '80px', + render: (row) => ( + {row.use} + ), + }, + { + key: 'x', + label: t('jwks.colPublicKey'), + render: (row) => + row.x ? ( +
+ + {row.x.slice(0, 20)}... + + +
+ ) : ( + {'\u2014'} + ), + }, + ]; + + return ( +
+ + +
+
+
{t('jwks.publicKeys')}
+ {jwksData && ( + + )} +
+ + {jwksError && ( +
+ {t('jwks.failedToLoad')} {jwksError instanceof Error ? jwksError.message : t('common.unknownError')} +
+ )} + + row.kid} + isLoading={jwksLoading} + emptyMessage={t('jwks.emptyMessage')} + /> + +
+ {t('jwks.endpoint')} /.well-known/elydora/jwks.json +
+
+
+ ); +} diff --git a/packages/console/src/app/layout.tsx b/packages/console/src/app/layout.tsx new file mode 100644 index 0000000..79125ea --- /dev/null +++ b/packages/console/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import AppShell from '@/components/AppShell'; +import I18nProvider from '@/components/I18nProvider'; + +export const metadata: Metadata = { + title: 'Elydora Console', + description: 'Enterprise AI accountability and compliance console', + icons: { icon: '/favicon.svg' }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/packages/console/src/app/login/page.tsx b/packages/console/src/app/login/page.tsx new file mode 100644 index 0000000..dcd36f2 --- /dev/null +++ b/packages/console/src/app/login/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { useTranslation } from 'react-i18next'; + +export default function LoginPage() { + const { t } = useTranslation(); + const router = useRouter(); + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + try { + const result = await api.auth.login(email, password); + login(result.token, { display_name: result.user.display_name, email: result.user.email }); + router.push('/'); + } catch (err) { + setError(err instanceof Error ? err.message : t('login.loginFailed')); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+ {/* Logo */} +
+
+
+ E +
+
+
ELYDORA
+
Console
+
+
+
+ + {/* Form */} +
+

{t('login.signIn')}

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors" + placeholder={t('login.emailPlaceholder')} + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors" + /> +
+ +
+ +
+ {t('login.noAccount')} + + {t('login.createOne')} + +
+
+
+
+ ); +} diff --git a/packages/console/src/app/operations/[operation_id]/client.tsx b/packages/console/src/app/operations/[operation_id]/client.tsx new file mode 100644 index 0000000..76e2f1c --- /dev/null +++ b/packages/console/src/app/operations/[operation_id]/client.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import OperationDetailClient from '@/components/OperationDetailClient'; + +export default function OperationDetailShell() { + const [operationId, setOperationId] = useState(''); + useEffect(() => { + setOperationId(window.location.pathname.split('/')[2] ?? ''); + }, []); + if (!operationId) return null; + return ; +} diff --git a/packages/console/src/app/operations/[operation_id]/page.tsx b/packages/console/src/app/operations/[operation_id]/page.tsx new file mode 100644 index 0000000..d84d011 --- /dev/null +++ b/packages/console/src/app/operations/[operation_id]/page.tsx @@ -0,0 +1,9 @@ +import OperationDetailShell from './client'; + +export async function generateStaticParams() { + return [{ operation_id: 'detail' }]; +} + +export default function OperationDetailPage() { + return ; +} diff --git a/packages/console/src/app/operations/page.tsx b/packages/console/src/app/operations/page.tsx new file mode 100644 index 0000000..3c807e6 --- /dev/null +++ b/packages/console/src/app/operations/page.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { Suspense, useState, useCallback, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslation } from 'react-i18next'; +import { useAudit, formatRelativeTime } from '@/lib/hooks'; +import PageHeader from '@/components/ui/PageHeader'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +import SearchInput from '@/components/ui/SearchInput'; +import type { Operation } from '@elydora/shared'; + +function OperationsContent() { + const { t } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const initialAgentId = searchParams.get('agent_id') ?? ''; + + const [search, setSearch] = useState(''); + const [agentFilter, setAgentFilter] = useState(initialAgentId); + const [typeFilter, setTypeFilter] = useState(''); + const [cursor, setCursor] = useState(undefined); + + const { data, isLoading, error } = useAudit({ + agent_id: agentFilter || undefined, + operation_type: typeFilter || undefined, + cursor, + limit: 50, + }); + + const operations = data?.operations ?? []; + + const filteredOps = useMemo(() => { + if (!search.trim()) return operations; + const q = search.toLowerCase(); + return operations.filter( + (op) => + op.operation_id.toLowerCase().includes(q) || + op.agent_id.toLowerCase().includes(q) || + op.operation_type.toLowerCase().includes(q), + ); + }, [operations, search]); + + const columns: Column>[] = [ + { + key: 'operation_id', + label: t('operations.colOperationId'), + render: (row) => ( + + {row.operation_id.slice(0, 20)}... + + ), + }, + { + key: 'agent_id', + label: t('operations.colAgent'), + sortable: true, + render: (row) => ( + {row.agent_id} + ), + }, + { + key: 'operation_type', + label: t('operations.colType'), + sortable: true, + render: (row) => ( + + {row.operation_type} + + ), + }, + { + key: 'issued_at', + label: t('operations.colIssuedAt'), + sortable: true, + render: (row) => ( + + {formatRelativeTime(row.issued_at)} + + ), + }, + { + key: 'seq_no', + label: t('operations.colSeq'), + sortable: true, + width: '80px', + render: (row) => ( + #{row.seq_no} + ), + }, + ]; + + const handleRowClick = useCallback( + (row: Operation & Record) => { + router.push(`/operations/${row.operation_id}`); + }, + [router], + ); + + return ( + <> + {error && ( +
+ {t('operations.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')} +
+ )} + + {/* Filter Bar */} +
+ +
+ + setAgentFilter(e.target.value)} + placeholder={t('operations.filterByAgent')} + className="w-full px-3 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors" + /> +
+
+ + setTypeFilter(e.target.value)} + placeholder={t('operations.filterByType')} + className="w-full px-3 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors" + /> +
+
+ + )[]} + keyExtractor={(row) => row.operation_id as string} + onRowClick={handleRowClick} + isLoading={isLoading} + emptyMessage={t('operations.emptyMessage')} + pagination={ + data?.cursor + ? { + cursor: data.cursor, + hasMore: true, + onLoadMore: () => setCursor(data.cursor), + } + : undefined + } + /> + + ); +} + +export default function OperationsPage() { + const { t } = useTranslation(); + return ( +
+ + +
+
+
+
+
+
+
+ }> + + +
+ ); +} diff --git a/packages/console/src/app/page.tsx b/packages/console/src/app/page.tsx new file mode 100644 index 0000000..b275a60 --- /dev/null +++ b/packages/console/src/app/page.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useAgentsList, useAudit, useEpochs, useExports, formatTimestamp, formatRelativeTime } from '@/lib/hooks'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/ui/PageHeader'; +import Link from 'next/link'; + +function StatCard({ + label, + value, + subtitle, + isLoading, +}: { + label: string; + value: string | number; + subtitle?: string; + isLoading?: boolean; +}) { + return ( +
+
{label}
+ {isLoading ? ( +
+ ) : ( +
+ {value} +
+ )} + {subtitle && ( +
{subtitle}
+ )} +
+ ); +} + +export default function DashboardPage() { + const { t } = useTranslation(); + const { data: agentsData, isLoading: agentsLoading } = useAgentsList(); + const { data: auditData, isLoading: auditLoading, error: auditError } = useAudit({}); + const { data: epochsData, isLoading: epochsLoading } = useEpochs(); + const { data: exportsData, isLoading: exportsLoading } = useExports(); + + const agentsList = agentsData?.agents ?? []; + const operations = auditData?.operations ?? []; + const recentOps = operations.slice(0, 10); + const totalOps = auditData?.total_count ?? 0; + const epochs = epochsData?.epochs ?? []; + const exports = exportsData?.exports ?? []; + const pendingExports = exports.filter((e) => e.status === 'queued' || e.status === 'running'); + + return ( +
+ + + {auditError && ( +
+ {t('dashboard.failedToLoad')} +
+ )} + + {/* Stats Grid */} +
+ + + + +
+ + {/* Quick Actions */} +
+
{t('dashboard.quickActions')}
+
+ + {t('dashboard.registerAgent')} + + + {t('dashboard.viewOperations')} + + + {t('dashboard.createExport')} + +
+
+ + {/* Recent Operations */} +
+
+
{t('dashboard.recentOperations')}
+ + {t('common.viewAll')} + +
+ +
+ {auditLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+ ) : recentOps.length === 0 ? ( +
+

+ {t('dashboard.noOperations')} +

+
+ ) : ( +
+ + + + + + + + + + + + {recentOps.map((op) => ( + + + + + + + + ))} + +
{t('dashboard.colOperationId')}{t('dashboard.colAgent')}{t('dashboard.colType')}{t('dashboard.colIssued')}{t('dashboard.colSeq')}
+ + {op.operation_id.slice(0, 16)}... + + + {op.agent_id} + + + {op.operation_type} + + + {formatRelativeTime(op.issued_at)} + + #{op.seq_no} +
+
+ )} +
+
+ + {/* Agent Status Summary */} +
+
{t('dashboard.agentSummary')}
+
+ {agentsLoading ? ( +
+
+
+
+
+ ) : agentsList.length === 0 ? ( +

+ {t('dashboard.noAgentsRegistered')} +

+ ) : ( +
+
+
+ {t('dashboard.totalAgents')} +
+
+ {agentsList.length} +
+
+
+
+ {t('dashboard.totalOperations')} +
+
+ {totalOps} +
+
+ {operations.length > 0 && operations[0] != null && ( +
+
+ {t('dashboard.latestActivity')} +
+
+ {formatTimestamp(operations[0].issued_at)} +
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/packages/console/src/app/register/page.tsx b/packages/console/src/app/register/page.tsx new file mode 100644 index 0000000..75a1565 --- /dev/null +++ b/packages/console/src/app/register/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { useTranslation } from 'react-i18next'; + +export default function RegisterPage() { + const { t } = useTranslation(); + const router = useRouter(); + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [orgName, setOrgName] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + try { + const result = await api.auth.register(email, password, displayName, orgName); + login(result.token, { display_name: result.user.display_name, email: result.user.email }); + router.push('/'); + } catch (err) { + setError(err instanceof Error ? err.message : t('register.registrationFailed')); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+ {/* Logo */} +
+
+
+ E +
+
+
ELYDORA
+
Console
+
+
+
+ + {/* Form */} +
+

{t('register.createAccount')}

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setOrgName(e.target.value)} + required + className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors" + placeholder={t('register.orgPlaceholder')} + /> +
+
+ + setDisplayName(e.target.value)} + required + className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors" + placeholder={t('register.displayNamePlaceholder')} + /> +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors" + placeholder={t('register.emailPlaceholder')} + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors" + placeholder={t('register.passwordPlaceholder')} + /> +
+ +
+ +
+ {t('register.hasAccount')} + + {t('register.signIn')} + +
+
+
+
+ ); +} diff --git a/packages/console/src/app/settings/page.tsx b/packages/console/src/app/settings/page.tsx new file mode 100644 index 0000000..ea5c8c2 --- /dev/null +++ b/packages/console/src/app/settings/page.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useAuth } from '@/lib/auth'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/ui/PageHeader'; +import CopyButton from '@/components/ui/CopyButton'; + +export default function SettingsPage() { + const { t } = useTranslation(); + const { user, logout } = useAuth(); + + return ( +
+ + + {/* User Information */} +
+
{t('settings.user')}
+
+
+
+
+ {t('settings.displayName')} +
+
+ {user?.display_name ?? '\u2014'} +
+
+
+
+ {t('settings.email')} +
+
+ {user?.email ?? '\u2014'} +
+
+
+
+ {t('settings.subject')} +
+
+ {user?.sub ?? '\u2014'} + {user?.sub && } +
+
+
+
+ {t('settings.role')} +
+ + {user?.role ?? '\u2014'} + +
+
+
+
+ + {/* Organization */} +
+
{t('settings.organization')}
+
+
+
+ {t('settings.organizationId')} +
+
+ {user?.org_id ?? '\u2014'} + {user?.org_id && } +
+
+
+
+ + {/* Sign Out */} +
+ +
+
+ ); +} diff --git a/packages/console/src/components/AgentDetailClient.tsx b/packages/console/src/components/AgentDetailClient.tsx new file mode 100644 index 0000000..c1aed21 --- /dev/null +++ b/packages/console/src/components/AgentDetailClient.tsx @@ -0,0 +1,484 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useAgent, useAudit, formatTimestamp } from '@/lib/hooks'; +import { api } from '@/lib/api'; +import PageHeader from '@/components/ui/PageHeader'; +import StatusBadge from '@/components/ui/StatusBadge'; +import CopyButton from '@/components/ui/CopyButton'; +import DataTable from '@/components/ui/DataTable'; +import type { Column } from '@/components/ui/DataTable'; +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', +]); + +export default function AgentDetailClient({ agentId }: { agentId: string }) { + const { t } = useTranslation(); + + const { data: agentData, isLoading: agentLoading, error: agentError, mutate: mutateAgent } = useAgent(agentId); + const { data: opsData, isLoading: opsLoading } = useAudit({ agent_id: agentId }); + + const [showFreezeModal, setShowFreezeModal] = useState(false); + const [freezeReason, setFreezeReason] = useState(''); + const [freezing, setFreezing] = useState(false); + const [freezeError, setFreezeError] = useState(null); + + const [showUnfreezeModal, setShowUnfreezeModal] = useState(false); + const [unfreezeReason, setUnfreezeReason] = useState(''); + const [unfreezing, setUnfreezing] = useState(false); + const [unfreezeError, setUnfreezeError] = useState(null); + + const [revokeKeyId, setRevokeKeyId] = useState(null); + const [revokeReason, setRevokeReason] = useState(''); + const [revoking, setRevoking] = useState(false); + const [revokeError, setRevokeError] = useState(null); + + const agent = agentData?.agent; + const supportsFreeze = !agent?.integration_type || HOOKS_INTEGRATIONS.has(agent.integration_type); + const keys = agentData?.keys ?? []; + const operations = opsData?.operations ?? []; + + const handleFreeze = useCallback(async () => { + if (!freezeReason.trim()) { + setFreezeError(t('agentDetail.reasonRequired')); + return; + } + setFreezing(true); + setFreezeError(null); + try { + await api.agents.freeze(agentId, freezeReason.trim()); + setShowFreezeModal(false); + setFreezeReason(''); + mutateAgent(); + } catch (err) { + setFreezeError(err instanceof Error ? err.message : t('agentDetail.failedToFreeze')); + } finally { + setFreezing(false); + } + }, [agentId, freezeReason, mutateAgent, t]); + + const handleUnfreeze = useCallback(async () => { + if (!unfreezeReason.trim()) { + setUnfreezeError(t('agentDetail.reasonRequired')); + return; + } + setUnfreezing(true); + setUnfreezeError(null); + try { + await api.agents.unfreeze(agentId, unfreezeReason.trim()); + setShowUnfreezeModal(false); + setUnfreezeReason(''); + mutateAgent(); + } catch (err) { + setUnfreezeError(err instanceof Error ? err.message : t('agentDetail.failedToUnfreeze')); + } finally { + setUnfreezing(false); + } + }, [agentId, unfreezeReason, mutateAgent, t]); + + const handleRevokeKey = useCallback(async () => { + if (!revokeKeyId || !revokeReason.trim()) { + setRevokeError(t('agentDetail.reasonRequired')); + return; + } + setRevoking(true); + setRevokeError(null); + try { + await api.agents.revokeKey(agentId, revokeKeyId, revokeReason.trim()); + setRevokeKeyId(null); + setRevokeReason(''); + mutateAgent(); + } catch (err) { + setRevokeError(err instanceof Error ? err.message : t('agentDetail.failedToRevoke')); + } finally { + setRevoking(false); + } + }, [agentId, revokeKeyId, revokeReason, mutateAgent, t]); + + const keyColumns: Column>[] = [ + { + key: 'kid', + label: t('agentDetail.colKeyId'), + render: (row) => ( +
+ {row.kid} + +
+ ), + }, + { + key: 'algorithm', + label: t('agentDetail.colAlgorithm'), + width: '120px', + render: (row) => ( + + {row.algorithm} + + ), + }, + { + key: 'status', + label: t('agentDetail.colStatus'), + width: '120px', + render: (row) => , + }, + { + key: 'created_at', + label: t('agentDetail.colCreated'), + render: (row) => ( + + {formatTimestamp(row.created_at)} + + ), + }, + { + key: 'actions', + label: '', + width: '80px', + render: (row) => + row.status === 'active' ? ( + + ) : null, + }, + ]; + + const opColumns: Column>[] = [ + { + key: 'operation_id', + label: t('operationCard.operationId'), + render: (row) => ( + + {row.operation_id.slice(0, 16)}... + + ), + }, + { + key: 'operation_type', + label: t('operationCard.type'), + render: (row) => ( + + {row.operation_type} + + ), + }, + { + key: 'issued_at', + label: t('operationCard.issuedAt'), + sortable: true, + render: (row) => ( + + {formatTimestamp(row.issued_at)} + + ), + }, + { + key: 'seq_no', + label: t('operationCard.seqNo'), + width: '80px', + render: (row) => ( + #{row.seq_no} + ), + }, + ]; + + if (agentLoading) { + return ( +
+ +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (agentError) { + return ( +
+ +
+ {t('agentDetail.failedToLoad')} {agentError instanceof Error ? agentError.message : t('agentDetail.mayNotExist')} +
+
+ ); + } + + return ( +
+ setShowFreezeModal(true)} + > + {t('agentDetail.freezeAgent')} + + ) : supportsFreeze && agent?.status === 'frozen' ? ( + + ) : undefined + } + /> + + {/* Agent Info Card */} +
+ + + + + +
+
+
{t('agentDetail.agentId')}
+
+ {agent?.agent_id} + {agent?.agent_id && } +
+
+
+
{t('agentDetail.status')}
+ {agent?.status && } +
+
+
{t('agentDetail.organization')}
+ {agent?.org_id} +
+
+
{t('agentDetail.displayName')}
+ + {agent?.display_name ?? '\u2014'} + +
+
+
{t('agentDetail.responsibleEntity')}
+ + {agent?.responsible_entity ?? '\u2014'} + +
+
+
{t('agentDetail.created')}
+ + {agent?.created_at ? formatTimestamp(agent.created_at) : '\u2014'} + +
+
+
+ + {/* Keys */} +
+
{t('agentDetail.keys')} ({keys.length})
+ )[]} + keyExtractor={(row) => row.kid} + emptyMessage={t('agentDetail.noKeys')} + /> +
+ + {/* Recent Operations */} +
+
+
+ {t('agentDetail.operations')} ({opsData?.total_count ?? 0}) +
+ + {t('common.viewAll')} + +
+ )[]} + keyExtractor={(row) => row.operation_id} + isLoading={opsLoading} + emptyMessage={t('agentDetail.noOperations')} + /> +
+ + {/* Freeze Modal */} + { + setShowFreezeModal(false); + setFreezeError(null); + }} + title={t('agentDetail.freezeAgent')} + > +
+

+ {t('agentDetail.freezeDescPre')}{agentId}{t('agentDetail.freezeDescPost')} +

+ {freezeError && ( +
+ {freezeError} +
+ )} +
+ +