Initial commit: Elydora Open Source — self-hosted audit platform for AI agents
Full self-hosted port of the Elydora tamper-evident audit protocol: - Hono API server with PostgreSQL, Redis, MinIO adapters - Ed25519 signed operations, chain hashing, Merkle epoch rollups - PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC - Next.js management console - Node.js, Python, and Go SDKs - Docker Compose for local deployment - Helm chart for Kubernetes - Comprehensive security hardening and audit fixes applied Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
15547a8c35
commit
e390e68594
207 files changed
+32790
-63
No files matched your search
@@ -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
|
||||
+43
@@ -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/
|
||||
+243
@@ -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/<language>/` 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
<div align="center">
|
||||
|
||||
# Elydora
|
||||
|
||||
**The Responsibility Layer for AI Agents**
|
||||
|
||||
Tamper-evident audit trails with cryptographic proof for every AI agent action.
|
||||
|
||||
[](LICENSE)
|
||||
[](sdks/node/)
|
||||
[](sdks/python/)
|
||||
[](sdks/go/)
|
||||
|
||||
[Quick Start](#quick-start) · [Documentation](#documentation) · [API Reference](#api-reference) · [SDKs](#sdks) · [Contributing](#contributing)
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
## 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": "<jwt>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `POST /v1/auth/login`
|
||||
|
||||
Authenticate an existing user.
|
||||
|
||||
**Request body:** `{ "email": "...", "password": "..." }`
|
||||
|
||||
**Response:** `{ "user": { ... }, "token": "<jwt>" }`
|
||||
|
||||
---
|
||||
|
||||
#### `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": "<api-token>", "expires_at": <ms> | 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": "<base64url Ed25519 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": <EAR> }` — 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": "<base64url SHA-256 Merkle root>",
|
||||
"leaf_count": 500,
|
||||
"created_at": 1700003601234
|
||||
},
|
||||
"anchor": {
|
||||
"tsa_token": "<base64 RFC3161 TimeStampToken>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ include "elydora.namespace" . }}
|
||||
labels:
|
||||
{{- include "elydora.labels" . | nindent 4 }}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
*.md
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1794
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="7" fill="#121212"/>
|
||||
<path d="M9 8H23V12H13V14H19V18H13V20H23V24H9Z" fill="#EAEAE5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -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 <AgentDetailClient agentId={agentId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AgentDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ agent_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
return <AgentDetailShell />;
|
||||
}
|
||||
@@ -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 <span className="font-mono text-[13px] text-ink-dim">—</span>;
|
||||
return <span className="font-mono text-[13px] text-ink-dim">{data?.total_count ?? 0}</span>;
|
||||
}
|
||||
|
||||
/** 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 <span className="font-mono text-[12px] text-ink-dim">—</span>;
|
||||
const latest = data?.operations?.[0]?.issued_at ?? fallback;
|
||||
return <span className="font-mono text-[12px] text-ink-dim">{formatRelativeTime(latest)}</span>;
|
||||
}
|
||||
|
||||
function AgentsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AgentRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(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<AgentRow>[] = [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('agents.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<div>
|
||||
<span className="font-mono text-[13px] text-ink font-medium">{row.agent_id}</span>
|
||||
{row.display_name && row.display_name !== row.agent_id && (
|
||||
<span className="ml-2 font-mono text-[11px] text-ink-dim">{row.display_name}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('agents.colStatus'),
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span
|
||||
className={`font-mono text-[11px] uppercase tracking-wider ${
|
||||
row.status === 'active'
|
||||
? 'text-ink'
|
||||
: row.status === 'frozen'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_count',
|
||||
label: t('agents.colOperations'),
|
||||
width: '120px',
|
||||
render: (row) => <AgentOpCount agentId={row.agent_id} />,
|
||||
},
|
||||
{
|
||||
key: 'latest_activity',
|
||||
label: t('agents.colLatestActivity'),
|
||||
render: (row) => <AgentLatestActivity agentId={row.agent_id} fallback={row.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-2 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(row);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agents.title')}
|
||||
subtitle={t('agents.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('agents.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowRegister(true)}>
|
||||
{t('agents.registerAgent')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{agentsError && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('agents.failedToLoad')} {agentsError instanceof Error ? agentsError.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('agents.searchPlaceholder')}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredAgents}
|
||||
keyExtractor={(row) => row.agent_id}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={agentsLoading}
|
||||
emptyMessage={t('agents.emptyMessage')}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showRegister}
|
||||
onClose={() => setShowRegister(false)}
|
||||
title={t('agents.registerAgent')}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<AgentRegistrationForm
|
||||
onSuccess={handleRegisterSuccess}
|
||||
onCancel={() => setShowRegister(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
title={t('agents.deleteAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agents.deleteConfirmPre')}<strong className="text-ink font-mono text-[13px]">{deleteTarget?.agent_id}</strong>{t('agents.deleteConfirmPost')}
|
||||
</p>
|
||||
{deleteError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-brutalist bg-red-600 text-white border-red-700 hover:bg-red-700"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? t('agents.deleting') : t('agents.deleteAgent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
|
||||
<AgentsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<string | undefined>(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<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('audit.colOperationId'),
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/operations/${row.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{row.operation_id.slice(0, 18)}...
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('audit.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/agents/${row.agent_id}`}
|
||||
className="font-mono text-[13px] text-ink-dim hover:text-ink no-underline transition-colors"
|
||||
>
|
||||
{row.agent_id}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('audit.colType'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('audit.colIssuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim" title={formatTimestamp(row.issued_at)}>
|
||||
{formatRelativeTime(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('audit.colSeq'),
|
||||
sortable: true,
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'chain_hash',
|
||||
label: t('audit.colChainHash'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{row.chain_hash.slice(0, 12)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setAgentId('');
|
||||
setOperationType('');
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
setCursor(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('audit.title')}
|
||||
subtitle={t('audit.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('audit.title') },
|
||||
]}
|
||||
actions={
|
||||
<Link href="/exports" className="btn-ghost inline-block no-underline">
|
||||
{t('audit.exportData')}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('audit.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query Builder */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('audit.queryBuilder')}</h3>
|
||||
<button className="btn-ghost text-[10px] py-1 px-3" onClick={handleClearFilters}>
|
||||
{t('audit.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.agentId')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentId}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.operationType')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operationType}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.startTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.endTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex items-center gap-6">
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{t('audit.showing', { shown: operations.length, total: data.total_count })}
|
||||
</span>
|
||||
{data.cursor && (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{t('audit.moreResults')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={operations as (Operation & Record<string, unknown>)[]}
|
||||
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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <EpochDetailClient epochId={epochId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import EpochDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ epoch_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function EpochDetailPage() {
|
||||
return <EpochDetailShell />;
|
||||
}
|
||||
@@ -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<EpochRow>[] = [
|
||||
{
|
||||
key: 'epoch_id',
|
||||
label: t('epochs.colEpochId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink font-medium">
|
||||
{row.epoch_id.slice(0, 16)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'start_time',
|
||||
label: t('epochs.colTimeRange'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<div className="font-mono text-[11px] text-ink-dim">
|
||||
<div>{formatTimestamp(row.start_time)}</div>
|
||||
<div className="text-[10px]">to {formatTimestamp(row.end_time)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'leaf_count',
|
||||
label: t('epochs.colLeaves'),
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">{row.leaf_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'root_hash',
|
||||
label: t('epochs.colRootHash'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{truncateHash(row.root_hash, 10)}
|
||||
</span>
|
||||
<CopyButton text={row.root_hash} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('epochs.colCreated'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: EpochRow) => {
|
||||
router.push(`/epochs/${row.epoch_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochs.title')}
|
||||
subtitle={t('epochs.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('epochs.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('epochs.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={epochs}
|
||||
keyExtractor={(row) => row.epoch_id}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('epochs.emptyMessage')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EpochsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
|
||||
<EpochsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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<ExportRow>[] = [
|
||||
{
|
||||
key: 'export_id',
|
||||
label: t('exports.colExportId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{row.export_id.slice(0, 16)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('exports.colStatus'),
|
||||
width: '120px',
|
||||
render: (row) => <StatusBadge status={row.status as ExportStatus} size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'query_params',
|
||||
label: t('exports.colFilters'),
|
||||
render: (row) => {
|
||||
let params: Record<string, unknown> = {};
|
||||
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 <span className="font-mono text-[11px] text-ink-dim">{t('common.allData')}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{entries.map(([k, v]) => `${k}=${v}`).join(', ')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('exports.colCreated'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed_at',
|
||||
label: t('exports.colCompleted'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{row.completed_at ? formatTimestamp(row.completed_at) : '\u2014'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '100px',
|
||||
render: (row) =>
|
||||
row.status === 'done' ? (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(row.export_id, row.query_params);
|
||||
}}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('exports.title')}
|
||||
subtitle={t('exports.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('exports.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowCreate(true)}>
|
||||
{t('exports.createExport')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('exports.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={exports}
|
||||
keyExtractor={(row) => row.export_id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('exports.emptyMessage')}
|
||||
/>
|
||||
|
||||
{/* Create Export Modal */}
|
||||
<Modal
|
||||
isOpen={showCreate}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
title={t('exports.createExport')}
|
||||
width="max-w-xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{createError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.startTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.endTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.agentIdOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentId}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.operationTypeOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operationType}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.format')}</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="json"
|
||||
checked={format === 'json'}
|
||||
onChange={() => setFormat('json')}
|
||||
className="accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[13px] text-ink">JSON</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="pdf"
|
||||
checked={format === 'pdf'}
|
||||
onChange={() => setFormat('pdf')}
|
||||
className="accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[13px] text-ink">PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleCreate} disabled={creating}>
|
||||
{creating ? t('exports.creating') : t('exports.createExport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
export default function GlobalError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
|
||||
<h2>Something went wrong</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<JWKRow>[] = [
|
||||
{
|
||||
key: 'kid',
|
||||
label: t('jwks.colKeyId'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink font-medium">{row.kid}</span>
|
||||
<CopyButton text={row.kid} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kty',
|
||||
label: t('jwks.colKeyType'),
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.kty}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'alg',
|
||||
label: t('jwks.colAlgorithm'),
|
||||
width: '120px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">{row.alg}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'crv',
|
||||
label: t('jwks.colCurve'),
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">{row.crv ?? '\u2014'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'use',
|
||||
label: t('jwks.colUse'),
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] text-ink-dim uppercase">{row.use}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'x',
|
||||
label: t('jwks.colPublicKey'),
|
||||
render: (row) =>
|
||||
row.x ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{row.x.slice(0, 20)}...
|
||||
</span>
|
||||
<CopyButton text={row.x} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-ink-dim">{'\u2014'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('jwks.title')}
|
||||
subtitle={t('jwks.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('jwks.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">{t('jwks.publicKeys')}</div>
|
||||
{jwksData && (
|
||||
<CopyButton
|
||||
text={JSON.stringify(jwksData, null, 2)}
|
||||
label={t('common.copyJwks')}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jwksError && (
|
||||
<div className="mb-4 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('jwks.failedToLoad')} {jwksError instanceof Error ? jwksError.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={jwkColumns}
|
||||
data={keys}
|
||||
keyExtractor={(row) => row.kid}
|
||||
isLoading={jwksLoading}
|
||||
emptyMessage={t('jwks.emptyMessage')}
|
||||
/>
|
||||
|
||||
<div className="mt-3 font-mono text-[11px] text-ink-dim">
|
||||
{t('jwks.endpoint')} <span className="text-ink">/.well-known/elydora/jwks.json</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className="bg-bg text-ink font-sans antialiased">
|
||||
<I18nProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-ink flex items-center justify-center">
|
||||
<span className="font-mono text-lg font-bold text-ink">E</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
|
||||
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="border border-border bg-surface p-6 sm:p-8">
|
||||
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('login.signIn')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('login.signingIn') : t('login.submitSignIn')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<span className="font-mono text-xs text-ink-dim">{t('login.noAccount')}</span>
|
||||
<Link href="/register" className="font-mono text-xs text-ink underline hover:no-underline">
|
||||
{t('login.createOne')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <OperationDetailClient operationId={operationId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import OperationDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ operation_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function OperationDetailPage() {
|
||||
return <OperationDetailShell />;
|
||||
}
|
||||
@@ -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<string | undefined>(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<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('operations.colOperationId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{row.operation_id.slice(0, 20)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('operations.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">{row.agent_id}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('operations.colType'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('operations.colIssuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatRelativeTime(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('operations.colSeq'),
|
||||
sortable: true,
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: Operation & Record<string, unknown>) => {
|
||||
router.push(`/operations/${row.operation_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('operations.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('operations.searchPlaceholder')}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('operations.agentIdFilter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('operations.typeFilter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredOps as (Operation & Record<string, unknown>)[]}
|
||||
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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operations.title')}
|
||||
subtitle={t('operations.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('operations.title') },
|
||||
]}
|
||||
/>
|
||||
<Suspense fallback={
|
||||
<div className="border border-border p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-10 w-full max-w-md" />
|
||||
<div className="skeleton h-6 w-full" />
|
||||
<div className="skeleton h-6 w-full" />
|
||||
<div className="skeleton h-6 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<OperationsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="stat-card">
|
||||
<div className="section-label mb-3">{label}</div>
|
||||
{isLoading ? (
|
||||
<div className="skeleton h-8 w-20 mb-1" />
|
||||
) : (
|
||||
<div className="font-sans text-3xl font-semibold tracking-tight text-ink leading-none">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="font-mono text-[11px] text-ink-dim mt-2">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('dashboard.title')}
|
||||
subtitle={t('dashboard.subtitle')}
|
||||
/>
|
||||
|
||||
{auditError && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('dashboard.failedToLoad')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
|
||||
<StatCard
|
||||
label={t('dashboard.totalAgents')}
|
||||
value={agentsList.length}
|
||||
subtitle={t('dashboard.registeredAgents')}
|
||||
isLoading={agentsLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.totalOperations')}
|
||||
value={totalOps}
|
||||
subtitle={t('dashboard.allRecordedOps')}
|
||||
isLoading={auditLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.activeEpochs')}
|
||||
value={epochs.length}
|
||||
subtitle={t('dashboard.merkleRollups')}
|
||||
isLoading={epochsLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.pendingExports')}
|
||||
value={pendingExports.length}
|
||||
subtitle={t('dashboard.queuedOrRunning')}
|
||||
isLoading={exportsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('dashboard.quickActions')}</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/agents" className="btn-brutalist inline-block no-underline">
|
||||
{t('dashboard.registerAgent')}
|
||||
</Link>
|
||||
<Link href="/operations" className="btn-ghost inline-block no-underline">
|
||||
{t('dashboard.viewOperations')}
|
||||
</Link>
|
||||
<Link href="/exports" className="btn-ghost inline-block no-underline">
|
||||
{t('dashboard.createExport')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Operations */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">{t('dashboard.recentOperations')}</div>
|
||||
<Link
|
||||
href="/operations"
|
||||
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border border-border overflow-hidden">
|
||||
{auditLoading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-4 w-24" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentOps.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('dashboard.noOperations')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colOperationId')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colAgent')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colType')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colIssued')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colSeq')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentOps.map((op) => (
|
||||
<tr key={op.operation_id} className="data-row border-b border-border last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/operations/${op.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{op.operation_id.slice(0, 16)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
|
||||
{op.agent_id}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{op.operation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] text-ink-dim">
|
||||
{formatRelativeTime(op.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
|
||||
#{op.seq_no}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Status Summary */}
|
||||
<div>
|
||||
<div className="section-label mb-3">{t('dashboard.agentSummary')}</div>
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
{agentsLoading ? (
|
||||
<div className="flex gap-8">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
) : agentsList.length === 0 ? (
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('dashboard.noAgentsRegistered')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-8">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.totalAgents')}
|
||||
</div>
|
||||
<div className="font-sans text-xl font-semibold text-ink">
|
||||
{agentsList.length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.totalOperations')}
|
||||
</div>
|
||||
<div className="font-sans text-xl font-semibold text-ink">
|
||||
{totalOps}
|
||||
</div>
|
||||
</div>
|
||||
{operations.length > 0 && operations[0] != null && (
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.latestActivity')}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-ink">
|
||||
{formatTimestamp(operations[0].issued_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-ink flex items-center justify-center">
|
||||
<span className="font-mono text-lg font-bold text-ink">E</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
|
||||
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="border border-border bg-surface p-8">
|
||||
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('register.createAccount')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.orgName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.displayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('register.creatingAccount') : t('register.submitCreateAccount')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<span className="font-mono text-xs text-ink-dim">{t('register.hasAccount')}</span>
|
||||
<Link href="/login" className="font-mono text-xs text-ink underline hover:no-underline">
|
||||
{t('register.signIn')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('settings.title')}
|
||||
subtitle={t('settings.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('settings.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* User Information */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('settings.user')}</div>
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.displayName')}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">
|
||||
{user?.display_name ?? '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.email')}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">
|
||||
{user?.email ?? '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.subject')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{user?.sub ?? '\u2014'}</span>
|
||||
{user?.sub && <CopyButton text={user.sub} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.role')}
|
||||
</div>
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-bg border border-border text-ink">
|
||||
{user?.role ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('settings.organization')}</div>
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.organizationId')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{user?.org_id ?? '\u2014'}</span>
|
||||
{user?.org_id && <CopyButton text={user.org_id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign Out */}
|
||||
<div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-4 py-2 font-mono text-[12px] uppercase tracking-wider border border-border bg-surface text-ink hover:bg-bg transition-colors"
|
||||
>
|
||||
{t('common.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const [showUnfreezeModal, setShowUnfreezeModal] = useState(false);
|
||||
const [unfreezeReason, setUnfreezeReason] = useState('');
|
||||
const [unfreezing, setUnfreezing] = useState(false);
|
||||
const [unfreezeError, setUnfreezeError] = useState<string | null>(null);
|
||||
|
||||
const [revokeKeyId, setRevokeKeyId] = useState<string | null>(null);
|
||||
const [revokeReason, setRevokeReason] = useState('');
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [revokeError, setRevokeError] = useState<string | null>(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<AgentKey & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'kid',
|
||||
label: t('agentDetail.colKeyId'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{row.kid}</span>
|
||||
<CopyButton text={row.kid} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'algorithm',
|
||||
label: t('agentDetail.colAlgorithm'),
|
||||
width: '120px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.algorithm}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('agentDetail.colStatus'),
|
||||
width: '120px',
|
||||
render: (row) => <StatusBadge status={row.status} size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('agentDetail.colCreated'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '80px',
|
||||
render: (row) =>
|
||||
row.status === 'active' ? (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRevokeKeyId(row.kid);
|
||||
}}
|
||||
>
|
||||
{t('agentDetail.revoke')}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
const opColumns: Column<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('operationCard.operationId'),
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/operations/${row.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{row.operation_id.slice(0, 16)}...
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('operationCard.type'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('operationCard.issuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('operationCard.seqNo'),
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (agentLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agentDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.agents'), href: '/agents' },
|
||||
{ label: agentId },
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-6 w-64" />
|
||||
<div className="skeleton h-4 w-48" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (agentError) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agentDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.agents'), href: '/agents' },
|
||||
{ label: agentId },
|
||||
]}
|
||||
/>
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('agentDetail.failedToLoad')} {agentError instanceof Error ? agentError.message : t('agentDetail.mayNotExist')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={agent?.display_name ?? agentId}
|
||||
subtitle={agent?.display_name ? agentId : undefined}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.agents'), href: '/agents' },
|
||||
{ label: agent?.display_name ?? agentId },
|
||||
]}
|
||||
actions={
|
||||
supportsFreeze && agent?.status === 'active' ? (
|
||||
<button
|
||||
className="btn-brutalist"
|
||||
onClick={() => setShowFreezeModal(true)}
|
||||
>
|
||||
{t('agentDetail.freezeAgent')}
|
||||
</button>
|
||||
) : supportsFreeze && agent?.status === 'frozen' ? (
|
||||
<button
|
||||
className="btn-brutalist"
|
||||
onClick={() => setShowUnfreezeModal(true)}
|
||||
>
|
||||
{t('agentDetail.unfreezeAgent')}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Agent Info Card */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 mb-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.agentId')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[14px] text-ink break-all">{agent?.agent_id}</span>
|
||||
{agent?.agent_id && <CopyButton text={agent.agent_id} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.status')}</div>
|
||||
{agent?.status && <StatusBadge status={agent.status} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.organization')}</div>
|
||||
<span className="font-mono text-[13px] text-ink break-all">{agent?.org_id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.displayName')}</div>
|
||||
<span className="font-sans text-sm text-ink">
|
||||
{agent?.display_name ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.responsibleEntity')}</div>
|
||||
<span className="font-sans text-sm text-ink">
|
||||
{agent?.responsible_entity ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.created')}</div>
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{agent?.created_at ? formatTimestamp(agent.created_at) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keys */}
|
||||
<div className="mb-6">
|
||||
<div className="section-label mb-3">{t('agentDetail.keys')} ({keys.length})</div>
|
||||
<DataTable
|
||||
columns={keyColumns}
|
||||
data={keys as (AgentKey & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.kid}
|
||||
emptyMessage={t('agentDetail.noKeys')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Operations */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">
|
||||
{t('agentDetail.operations')} ({opsData?.total_count ?? 0})
|
||||
</div>
|
||||
<Link
|
||||
href={`/operations?agent_id=${agentId}`}
|
||||
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={opColumns}
|
||||
data={(operations.slice(0, 10)) as (Operation & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.operation_id}
|
||||
isLoading={opsLoading}
|
||||
emptyMessage={t('agentDetail.noOperations')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Freeze Modal */}
|
||||
<Modal
|
||||
isOpen={showFreezeModal}
|
||||
onClose={() => {
|
||||
setShowFreezeModal(false);
|
||||
setFreezeError(null);
|
||||
}}
|
||||
title={t('agentDetail.freezeAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agentDetail.freezeDescPre')}<strong className="text-ink">{agentId}</strong>{t('agentDetail.freezeDescPost')}
|
||||
</p>
|
||||
{freezeError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{freezeError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
|
||||
<textarea
|
||||
value={freezeReason}
|
||||
onChange={(e) => setFreezeReason(e.target.value)}
|
||||
placeholder={t('agentDetail.freezePlaceholder')}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowFreezeModal(false);
|
||||
setFreezeError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleFreeze} disabled={freezing}>
|
||||
{freezing ? t('agentDetail.freezing') : t('agentDetail.confirmFreeze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Unfreeze Modal */}
|
||||
<Modal
|
||||
isOpen={showUnfreezeModal}
|
||||
onClose={() => {
|
||||
setShowUnfreezeModal(false);
|
||||
setUnfreezeError(null);
|
||||
}}
|
||||
title={t('agentDetail.unfreezeAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agentDetail.unfreezeDescPre')}<strong className="text-ink">{agentId}</strong>{t('agentDetail.unfreezeDescPost')}
|
||||
</p>
|
||||
{unfreezeError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{unfreezeError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
|
||||
<textarea
|
||||
value={unfreezeReason}
|
||||
onChange={(e) => setUnfreezeReason(e.target.value)}
|
||||
placeholder={t('agentDetail.unfreezePlaceholder')}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowUnfreezeModal(false);
|
||||
setUnfreezeError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleUnfreeze} disabled={unfreezing}>
|
||||
{unfreezing ? t('agentDetail.unfreezing') : t('agentDetail.confirmUnfreeze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Revoke Key Modal */}
|
||||
<Modal
|
||||
isOpen={revokeKeyId !== null}
|
||||
onClose={() => {
|
||||
setRevokeKeyId(null);
|
||||
setRevokeError(null);
|
||||
}}
|
||||
title={t('agentDetail.revokeKey')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agentDetail.revokeKeyDescPre')}<strong className="text-ink font-mono text-[13px]">{revokeKeyId}</strong>{t('agentDetail.revokeKeyDescPost')}
|
||||
</p>
|
||||
{revokeError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{revokeError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
|
||||
<textarea
|
||||
value={revokeReason}
|
||||
onChange={(e) => setRevokeReason(e.target.value)}
|
||||
placeholder={t('agentDetail.revokePlaceholder')}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setRevokeKeyId(null);
|
||||
setRevokeError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleRevokeKey} disabled={revoking}>
|
||||
{revoking ? t('agentDetail.revoking') : t('agentDetail.confirmRevoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { AuthProvider, useAuth } from '@/lib/auth';
|
||||
import Sidebar from '@/components/ui/Sidebar';
|
||||
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const isPublicRoute = pathname === '/login' || pathname === '/register';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !isPublicRoute) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isPublicRoute, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen bg-bg" />;
|
||||
}
|
||||
|
||||
if (isPublicRoute) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div className="min-h-screen bg-bg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={closeSidebar} />
|
||||
|
||||
{/* Mobile header bar */}
|
||||
<div className="fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 h-14 bg-ink md:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 -ml-2 text-[#EAEAE5]"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M3 10h14M3 15h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="font-sans text-sm font-semibold tracking-wide text-[#EAEAE5]">ELYDORA</span>
|
||||
</div>
|
||||
|
||||
<main className="min-h-screen md:ml-[260px]">
|
||||
<div className="max-w-[1400px] mx-auto px-4 md:px-6 lg:px-12 pt-20 md:pt-8 pb-8 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import { truncateHash } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ChainBlock {
|
||||
label: string;
|
||||
hash: string;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
interface ChainVisualizationProps {
|
||||
prevHash: string;
|
||||
currentHash: string;
|
||||
operationId: string;
|
||||
}
|
||||
|
||||
function Block({ label, hash, isCurrent }: ChainBlock) {
|
||||
return (
|
||||
<div
|
||||
className={`border ${
|
||||
isCurrent ? 'border-ink bg-white' : 'border-border bg-surface'
|
||||
} p-3 sm:p-4 min-w-[140px] sm:min-w-[180px] shrink-0`}
|
||||
>
|
||||
<div className="section-label mb-2 truncate">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[11px] sm:text-[12px] text-ink">
|
||||
{truncateHash(hash, 8)}
|
||||
</span>
|
||||
<CopyButton text={hash} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1 sm:px-2 shrink-0">
|
||||
<svg width="24" height="12" viewBox="0 0 24 12" fill="none" className="text-ink-dim sm:w-8">
|
||||
<path d="M0 6h20M16 1l5 5-5 5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChainVisualization({
|
||||
prevHash,
|
||||
currentHash,
|
||||
operationId,
|
||||
}: ChainVisualizationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 overflow-hidden">
|
||||
<h3 className="section-label mb-4">{t('chain.title')}</h3>
|
||||
|
||||
<div className="flex items-center overflow-x-auto pb-2 -mx-1 px-1">
|
||||
<Block label={t('chain.previous')} hash={prevHash} />
|
||||
<Arrow />
|
||||
<Block label={t('chain.current', { id: truncateHash(operationId, 6) })} hash={currentHash} isCurrent />
|
||||
<Arrow />
|
||||
<div className="border border-border border-dashed p-3 sm:p-4 min-w-[140px] sm:min-w-[180px] bg-surface shrink-0">
|
||||
<div className="section-label mb-2">{t('chain.next')}</div>
|
||||
<span className="font-mono text-[12px] text-ink-dim italic">
|
||||
{t('chain.pending')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-[11px] font-mono text-ink-dim flex-wrap">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 border border-border bg-surface inline-block" />
|
||||
{t('chain.chainBlock')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 border border-ink bg-white inline-block" />
|
||||
{t('chain.currentOperation')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 border border-dashed border-border bg-surface inline-block" />
|
||||
{t('chain.pending')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useEpoch, formatTimestamp, truncateHash } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EpochDetailClient({ epochId }: { epochId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, error } = useEpoch(epochId);
|
||||
const epoch = data?.epoch;
|
||||
const anchor = data?.anchor;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.epochs'), href: '/epochs' },
|
||||
{ label: epochId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-6 w-64" />
|
||||
<div className="skeleton h-4 w-full" />
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !epoch) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.epochs'), href: '/epochs' },
|
||||
{ label: epochId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('epochDetail.failedToLoad')} {error instanceof Error ? error.message : t('epochDetail.mayNotExist')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const durationMs = epoch.end_time - epoch.start_time;
|
||||
const durationMinutes = Math.round(durationMs / 60000);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochDetail.title')}
|
||||
subtitle={epoch.epoch_id}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.epochs'), href: '/epochs' },
|
||||
{ label: epoch.epoch_id.slice(0, 16) + '...' },
|
||||
]}
|
||||
actions={
|
||||
<Link
|
||||
href={`/audit?start_time=${new Date(epoch.start_time).toISOString().slice(0, 16)}&end_time=${new Date(epoch.end_time).toISOString().slice(0, 16)}`}
|
||||
className="btn-ghost inline-block no-underline"
|
||||
>
|
||||
{t('epochDetail.viewOperations')}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Epoch Metadata */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<h3 className="section-label mb-4">{t('epochDetail.epochMetadata')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.epochId')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink break-all">{epoch.epoch_id}</span>
|
||||
<CopyButton text={epoch.epoch_id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.organization')}</div>
|
||||
<span className="font-mono text-[13px] text-ink">{epoch.org_id}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.startTime')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{formatTimestamp(epoch.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.endTime')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{formatTimestamp(epoch.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.duration')}</div>
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{t('epochDetail.durationMinutes', { count: durationMinutes })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.createdAt')}</div>
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(epoch.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merkle Root */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('epochDetail.merkleTree')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.rootHash')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink break-all">
|
||||
{epoch.root_hash}
|
||||
</span>
|
||||
<CopyButton text={epoch.root_hash} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.leafCount')}</div>
|
||||
<span className="font-sans text-2xl font-semibold text-ink">
|
||||
{epoch.leaf_count}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-ink-dim ml-2">
|
||||
{t('epochDetail.operationsUnit')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TSA Anchor */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('epochDetail.tsaAnchor')}</h3>
|
||||
|
||||
{anchor?.tsa_token ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<div className="section-label mb-1">{t('epochDetail.tsaToken')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[12px] text-ink break-all">
|
||||
{truncateHash(anchor.tsa_token, 20)}
|
||||
</span>
|
||||
<CopyButton text={anchor.tsa_token} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{anchor.tsa_url && (
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.tsaUrl')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{anchor.tsa_url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anchor.anchored_at && (
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.anchoredAt')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{formatTimestamp(anchor.anchored_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('epochDetail.noTsaAnchor')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* R2 Storage */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('epochDetail.storage')}</h3>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.r2EpochKey')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink break-all">
|
||||
{epoch.r2_epoch_key}
|
||||
</span>
|
||||
<CopyButton text={epoch.r2_epoch_key} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@/i18n/config';
|
||||
|
||||
export default function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language;
|
||||
const handleChange = (lng: string) => {
|
||||
document.documentElement.lang = lng;
|
||||
};
|
||||
i18n.on('languageChanged', handleChange);
|
||||
return () => { i18n.off('languageChanged', handleChange); };
|
||||
}, [i18n]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import { formatTimestamp, truncateHash } from '@/lib/hooks';
|
||||
import type { Operation, Receipt } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OperationDetailCardProps {
|
||||
operation: Operation;
|
||||
receipt?: Receipt;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FieldRowProps {
|
||||
label: string;
|
||||
value: string | number | null | undefined;
|
||||
mono?: boolean;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
function FieldRow({ label, value, mono = true, copyable = false }: FieldRowProps) {
|
||||
const displayValue = value == null ? '\u2014' : String(value);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start py-2.5 border-b border-border last:border-b-0 gap-1 sm:gap-0">
|
||||
<div className="sm:w-44 sm:shrink-0">
|
||||
<span className="section-label">{label}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={`${mono ? 'font-mono text-[13px]' : 'font-sans text-sm'} text-ink break-all`}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{copyable && value != null && (
|
||||
<CopyButton text={String(value)} className="shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationDetailCard({ operation, receipt, payload }: OperationDetailCardProps) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Operation record */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('operationCard.operationRecord')}</h3>
|
||||
<button
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="btn-ghost text-[10px] py-1 px-3"
|
||||
>
|
||||
{showRaw ? t('operationCard.formatted') : t('operationCard.rawJson')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRaw ? (
|
||||
<div className="relative">
|
||||
<pre className="font-mono text-[12px] text-ink bg-bg p-4 border border-border overflow-x-auto">
|
||||
{JSON.stringify(operation, null, 2)}
|
||||
</pre>
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={JSON.stringify(operation, null, 2)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FieldRow label={t('operationCard.operationId')} value={operation.operation_id} copyable />
|
||||
<FieldRow label={t('operationCard.agentId')} value={operation.agent_id} copyable />
|
||||
<FieldRow label={t('operationCard.orgId')} value={operation.org_id} copyable />
|
||||
<FieldRow label={t('operationCard.type')} value={operation.operation_type} />
|
||||
<FieldRow label={t('operationCard.issuedAt')} value={formatTimestamp(operation.issued_at)} mono={false} />
|
||||
<FieldRow label={t('operationCard.createdAt')} value={formatTimestamp(operation.created_at)} mono={false} />
|
||||
<FieldRow label={t('operationCard.ttlMs')} value={operation.ttl_ms} />
|
||||
<FieldRow label={t('operationCard.nonce')} value={operation.nonce} copyable />
|
||||
<FieldRow label={t('operationCard.seqNo')} value={operation.seq_no} />
|
||||
<FieldRow label={t('operationCard.signingKey')} value={operation.agent_pubkey_kid} copyable />
|
||||
<FieldRow label={t('operationCard.payloadHash')} value={truncateHash(operation.payload_hash, 12)} copyable />
|
||||
<FieldRow label={t('operationCard.prevChainHash')} value={truncateHash(operation.prev_chain_hash, 12)} copyable />
|
||||
<FieldRow label={t('operationCard.chainHash')} value={truncateHash(operation.chain_hash, 12)} copyable />
|
||||
<FieldRow label={t('operationCard.r2PayloadKey')} value={operation.r2_payload_key ?? '—'} copyable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payload — what the operation actually did */}
|
||||
{payload && (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('operationCard.payload')}</h3>
|
||||
<div className="flex-1" />
|
||||
<CopyButton text={JSON.stringify(payload, null, 2)} />
|
||||
</div>
|
||||
{Object.entries(payload).map(([key, value]) => {
|
||||
if (value == null) return null;
|
||||
const isComplex = typeof value === 'object';
|
||||
if (isComplex) {
|
||||
return (
|
||||
<div key={key} className="py-2.5 border-b border-border last:border-b-0">
|
||||
<div className="mb-1">
|
||||
<span className="section-label">{key.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<pre className="font-mono text-[12px] text-ink bg-bg p-3 border border-border max-h-64 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRow key={key} label={key.replace(/_/g, ' ')} value={String(value)} copyable />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt */}
|
||||
{receipt && (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('operationCard.receipt')}</h3>
|
||||
<FieldRow label={t('operationCard.receiptId')} value={receipt.receipt_id} copyable />
|
||||
<FieldRow label={t('operationCard.operationId')} value={receipt.operation_id} copyable />
|
||||
<FieldRow label={t('operationCard.r2ReceiptKey')} value={receipt.r2_receipt_key} copyable />
|
||||
<FieldRow label={t('operationCard.createdAt')} value={formatTimestamp(receipt.created_at)} mono={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useOperation } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import OperationDetailCard from '@/components/OperationDetailCard';
|
||||
import ChainVisualization from '@/components/ChainVisualization';
|
||||
import VerificationChecklist from '@/components/VerificationChecklist';
|
||||
import type { VerifyOperationResponse } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function OperationDetailClient({ operationId }: { operationId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, error } = useOperation(operationId);
|
||||
const [verifyResult, setVerifyResult] = useState<VerifyOperationResponse | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
const operation = data?.operation;
|
||||
const receipt = data?.receipt;
|
||||
const payload = data?.payload;
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
setVerifying(true);
|
||||
try {
|
||||
const result = await api.operations.verify(operationId);
|
||||
setVerifyResult(result);
|
||||
} catch (err) {
|
||||
setVerifyResult({
|
||||
valid: false,
|
||||
checks: {
|
||||
signature: false,
|
||||
chain: false,
|
||||
receipt: false,
|
||||
},
|
||||
errors: [err instanceof Error ? err.message : 'Verification request failed.'],
|
||||
});
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}, [operationId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operationDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.operations'), href: '/operations' },
|
||||
{ label: operationId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-6 w-64" />
|
||||
<div className="skeleton h-4 w-full" />
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
<div className="skeleton h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !operation) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operationDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.operations'), href: '/operations' },
|
||||
{ label: operationId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('operationDetail.failedToLoad')} {error instanceof Error ? error.message : t('operationDetail.mayNotExist')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operationDetail.title')}
|
||||
subtitle={operation.operation_id}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.operations'), href: '/operations' },
|
||||
{ label: operation.operation_id.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<OperationDetailCard operation={operation} receipt={receipt} payload={payload} />
|
||||
<ChainVisualization
|
||||
prevHash={operation.prev_chain_hash}
|
||||
currentHash={operation.chain_hash}
|
||||
operationId={operation.operation_id}
|
||||
/>
|
||||
<VerificationChecklist
|
||||
result={verifyResult}
|
||||
isLoading={verifying}
|
||||
onVerify={handleVerify}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import type { VerifyOperationResponse } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface VerificationChecklistProps {
|
||||
result: VerifyOperationResponse | null;
|
||||
isLoading: boolean;
|
||||
onVerify: () => void;
|
||||
}
|
||||
|
||||
interface CheckItemProps {
|
||||
label: string;
|
||||
description: string;
|
||||
passed: boolean | undefined;
|
||||
pendingText: string;
|
||||
passedText: string;
|
||||
failedText: string;
|
||||
}
|
||||
|
||||
function CheckItem({ label, description, passed, pendingText, passedText, failedText }: CheckItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3 border-b border-border last:border-b-0">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{passed === undefined ? (
|
||||
<div className="w-5 h-5 border border-border rounded-full" />
|
||||
) : passed ? (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="check-pass">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M6 10l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="check-fail">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M7 7l6 6M13 7l-6 6" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-mono text-[12px] font-medium uppercase tracking-wider text-ink">
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-sans text-[12px] text-ink-dim mt-0.5">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{passed === undefined ? (
|
||||
<span className="font-mono text-[11px] text-ink-dim uppercase">{pendingText}</span>
|
||||
) : passed ? (
|
||||
<span className="font-mono text-[11px] text-green-600 uppercase">{passedText}</span>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-red-600 uppercase">{failedText}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationChecklist({
|
||||
result,
|
||||
isLoading,
|
||||
onVerify,
|
||||
}: VerificationChecklistProps) {
|
||||
const { t } = useTranslation();
|
||||
const checks = result?.checks;
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('verification.title')}</h3>
|
||||
<button
|
||||
onClick={onVerify}
|
||||
className="btn-brutalist text-[10px] py-1.5 px-4"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('verification.verifying') : result ? t('verification.reVerify') : t('verification.verify')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mb-4 px-4 py-3 border font-mono text-[12px] flex items-center gap-2"
|
||||
style={{
|
||||
borderColor: result.valid ? '#16a34a' : '#dc2626',
|
||||
backgroundColor: result.valid ? 'rgba(22, 163, 74, 0.05)' : 'rgba(220, 38, 38, 0.05)',
|
||||
color: result.valid ? '#16a34a' : '#dc2626',
|
||||
}}
|
||||
>
|
||||
{result.valid ? (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 7.5l3 3 7-7" />
|
||||
</svg>
|
||||
{t('verification.verifiedSuccess')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 1l12 12M13 1L1 13" />
|
||||
</svg>
|
||||
{t('verification.verificationFailed')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<CheckItem
|
||||
label={t('verification.signature')}
|
||||
description={t('verification.signatureDesc')}
|
||||
passed={checks?.signature}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
<CheckItem
|
||||
label={t('verification.chainCheck')}
|
||||
description={t('verification.chainCheckDesc')}
|
||||
passed={checks?.chain}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
<CheckItem
|
||||
label={t('verification.receiptCheck')}
|
||||
description={t('verification.receiptCheckDesc')}
|
||||
passed={checks?.receipt}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
<CheckItem
|
||||
label={t('verification.merkle')}
|
||||
description={t('verification.merkleDesc')}
|
||||
passed={checks?.merkle}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result?.errors && result.errors.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="section-label mb-2">{t('verification.errors')}</div>
|
||||
<ul className="space-y-1">
|
||||
{result.errors.map((err, i) => (
|
||||
<li key={i} className="font-mono text-[12px] text-red-600">
|
||||
{err}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useClipboard } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CopyButton({ text, label, className = '' }: CopyButtonProps) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => copy(text)}
|
||||
className={`inline-flex items-center gap-1.5 font-mono text-[12px] text-ink-dim hover:text-ink transition-colors ${className}`}
|
||||
title={t('common.copyToClipboard')}
|
||||
>
|
||||
{label && <span>{label}</span>}
|
||||
{copied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-green-600">
|
||||
<path d="M2 7.5l3 3 7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="4" y="4" width="9" height="9" rx="1" />
|
||||
<path d="M10 4V2a1 1 0 00-1-1H2a1 1 0 00-1 1v7a1 1 0 001 1h2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
keyExtractor: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
pagination?: {
|
||||
cursor?: string;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
loadingMore?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function DataTable<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
isLoading,
|
||||
emptyMessage = 'No data available.',
|
||||
pagination,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDirection>('asc');
|
||||
|
||||
const handleSort = useCallback((key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}, [sortKey]);
|
||||
|
||||
const sortedData = sortKey
|
||||
? [...data].sort((a, b) => {
|
||||
const aVal = a[sortKey];
|
||||
const bVal = b[sortKey];
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortDir === 'asc' ? -1 : 1;
|
||||
if (bVal == null) return sortDir === 'asc' ? 1 : -1;
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortDir === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
}
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: data;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="border border-border">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="table-header text-left px-4 py-3"
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-b-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="border border-border bg-surface">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="table-header text-left px-4 py-3"
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div className="py-16 text-center">
|
||||
<p className="font-mono text-sm text-ink-dim">{emptyMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`table-header text-left px-4 py-3 ${
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-ink' : ''
|
||||
}`}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && sortKey === col.key && (
|
||||
<span className="text-ink">
|
||||
{sortDir === 'asc' ? '\u2191' : '\u2193'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map((row) => (
|
||||
<tr
|
||||
key={keyExtractor(row)}
|
||||
className={`data-row border-b border-border last:border-b-0 ${
|
||||
onRowClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 font-mono text-[13px]">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.hasMore && (
|
||||
<div className="border-t border-border px-4 py-3 flex justify-center">
|
||||
<button
|
||||
className="btn-ghost text-[11px]"
|
||||
onClick={pagination.onLoadMore}
|
||||
disabled={pagination.loadingMore}
|
||||
>
|
||||
{pagination.loadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function USFlag() {
|
||||
return (
|
||||
<svg width="20" height="14" viewBox="0 0 20 14" style={{ borderRadius: 2, flexShrink: 0 }}>
|
||||
<rect width="20" height="14" fill="#B22234" />
|
||||
<rect y="2" width="20" height="2" fill="#fff" />
|
||||
<rect y="6" width="20" height="2" fill="#fff" />
|
||||
<rect y="10" width="20" height="2" fill="#fff" />
|
||||
<rect width="8" height="8" fill="#3C3B6E" />
|
||||
<g fill="#fff">
|
||||
<circle cx="2" cy="1.5" r="0.5" />
|
||||
<circle cx="4" cy="1.5" r="0.5" />
|
||||
<circle cx="6" cy="1.5" r="0.5" />
|
||||
<circle cx="3" cy="3" r="0.5" />
|
||||
<circle cx="5" cy="3" r="0.5" />
|
||||
<circle cx="2" cy="4.5" r="0.5" />
|
||||
<circle cx="4" cy="4.5" r="0.5" />
|
||||
<circle cx="6" cy="4.5" r="0.5" />
|
||||
<circle cx="3" cy="6" r="0.5" />
|
||||
<circle cx="5" cy="6" r="0.5" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CNFlag() {
|
||||
return (
|
||||
<svg width="20" height="14" viewBox="0 0 20 14" style={{ borderRadius: 2, flexShrink: 0 }}>
|
||||
<rect width="20" height="14" fill="#DE2910" />
|
||||
<g fill="#FFDE00">
|
||||
<polygon points="4,1.5 4.7,3.6 6.9,3.6 5.1,5 5.8,7 4,5.7 2.2,7 2.9,5 1.1,3.6 3.3,3.6" />
|
||||
<polygon points="8.5,1 9,1.8 8.5,1.6 8,1.8" />
|
||||
<polygon points="10,2.5 10.3,3.3 9.7,2.9 9.3,3.3 9.5,2.5" />
|
||||
<polygon points="10,4.5 10.3,5.3 9.7,4.9 9.3,5.3 9.5,4.5" />
|
||||
<polygon points="8.5,6 9,6.8 8.5,6.6 8,6.8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', label: 'EN', flag: <USFlag /> },
|
||||
{ code: 'zh', label: '中文', flag: <CNFlag /> },
|
||||
];
|
||||
|
||||
export function LanguageDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const current = i18n.language.startsWith('zh') ? languages[1]! : languages[0]!;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 font-mono text-[11px] rounded transition-colors w-full"
|
||||
style={{
|
||||
color: '#EAEAE5',
|
||||
background: 'rgba(234,234,229,0.08)',
|
||||
border: '1px solid rgba(234,234,229,0.15)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{current.flag}
|
||||
<span>{current.label}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginLeft: 'auto', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}>
|
||||
<path d="M2 4l3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-50 rounded overflow-hidden"
|
||||
style={{
|
||||
bottom: '100%',
|
||||
marginBottom: 4,
|
||||
backgroundColor: '#1a1a18',
|
||||
border: '1px solid rgba(234,234,229,0.15)',
|
||||
}}
|
||||
>
|
||||
{languages.map((lang) => {
|
||||
const isActive = lang.code === 'en' ? i18n.language === 'en' : i18n.language.startsWith('zh');
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => { i18n.changeLanguage(lang.code); setOpen(false); }}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 font-mono text-[11px] transition-colors"
|
||||
style={{
|
||||
color: isActive ? '#EAEAE5' : 'rgba(234,234,229,0.5)',
|
||||
background: isActive ? 'rgba(234,234,229,0.1)' : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(234,234,229,0.06)'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
{lang.flag}
|
||||
{lang.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, width = 'max-w-lg' }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 md:left-[260px] z-[100] flex items-center justify-center modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${width} w-full mx-4 bg-bg border border-ink relative fade-in`}
|
||||
>
|
||||
{/* Crosshair corners */}
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
|
||||
<h2 className="font-sans text-lg font-semibold tracking-tight text-ink min-w-0 truncate">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center text-ink-dim hover:text-ink transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 1l12 12M13 1L1 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 py-5 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, subtitle, breadcrumbs, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="mb-3 flex items-center gap-2 flex-wrap overflow-hidden">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center gap-2 min-w-0">
|
||||
{index > 0 && (
|
||||
<span className="font-mono text-[11px] text-ink-dim shrink-0">/</span>
|
||||
)}
|
||||
{crumb.href ? (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className="font-mono text-[11px] uppercase tracking-wider text-ink-dim hover:text-ink transition-colors no-underline whitespace-nowrap"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-ink truncate">
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-sans text-2xl font-semibold tracking-tight text-ink leading-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-1 font-mono text-[13px] text-ink-dim break-all">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
className = '',
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-dim"
|
||||
>
|
||||
<circle cx="7" cy="7" r="5" />
|
||||
<path d="M11 11l4 4" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-10 pr-4 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LanguageDropdown } from './LanguageDropdown';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
title: t('sidebar.overview'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.dashboard'),
|
||||
href: '/',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.management'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.agents'),
|
||||
href: '/agents',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="5" r="3" />
|
||||
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.operations'),
|
||||
href: '/operations',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 4h12M2 8h12M2 12h12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.compliance'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.auditTrail'),
|
||||
href: '/audit',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 1v14M12 1v14M1 4h14M1 12h14" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.epochs'),
|
||||
href: '/epochs',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M8 4v4l3 3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.exports'),
|
||||
href: '/exports',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 2v8M5 7l3 3 3-3M3 12v2h10v-2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.system'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.jwks'),
|
||||
href: '/jwks',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 1l2 3h3l-2.5 3L12 11l-4-2-4 2 1.5-4L3 4h3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.settings'),
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay — mobile only */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed left-0 top-0 bottom-0 bg-ink text-[#EAEAE5] flex flex-col z-50 transition-transform duration-300 ease-in-out md:translate-x-0 ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||
style={{ width: 'var(--sidebar-width)' }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-6 border-b border-[rgba(234,234,229,0.1)]">
|
||||
<Link href="/" className="flex items-center gap-3 no-underline" onClick={onClose}>
|
||||
<div className="w-8 h-8 border border-[#EAEAE5] flex items-center justify-center">
|
||||
<span className="font-mono text-sm font-bold text-[#EAEAE5]">E</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-sans text-sm font-semibold tracking-wide text-[#EAEAE5]">
|
||||
ELYDORA
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-[rgba(234,234,229,0.4)] tracking-widest uppercase">
|
||||
{t('common.console')}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="mb-4">
|
||||
<div className="px-5 py-2 font-mono text-[10px] font-medium tracking-[0.15em] uppercase text-[rgba(234,234,229,0.3)]">
|
||||
{section.title}
|
||||
</div>
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={item.href + item.label}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={`sidebar-nav-item mx-2 rounded-sm ${isActive(item.href) ? 'active' : ''}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* External links */}
|
||||
<div className="px-5 py-3 border-t border-[rgba(234,234,229,0.1)]">
|
||||
<div className="font-mono text-[10px] font-medium tracking-[0.15em] uppercase text-[rgba(234,234,229,0.3)] mb-2">
|
||||
{t('common.links')}
|
||||
</div>
|
||||
<a
|
||||
href="https://elydora.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-[rgba(234,234,229,0.6)] hover:text-[#EAEAE5] transition-colors no-underline font-mono text-[12px]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 3H3v10h10v-3M9 2h5v5M14 2L7 9" />
|
||||
</svg>
|
||||
{t('common.home')}
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.elydora.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-[rgba(234,234,229,0.6)] hover:text-[#EAEAE5] transition-colors no-underline font-mono text-[12px]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 3H3v10h10v-3M9 2h5v5M14 2L7 9" />
|
||||
</svg>
|
||||
{t('common.docs')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Language dropdown */}
|
||||
<div className="px-5 py-3 border-t border-[rgba(234,234,229,0.1)]">
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
|
||||
{/* Bottom user info */}
|
||||
<div className="px-5 py-4 border-t border-[rgba(234,234,229,0.1)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[rgba(234,234,229,0.1)] flex items-center justify-center">
|
||||
<span className="font-mono text-xs text-[rgba(234,234,229,0.6)]">
|
||||
{user?.display_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-xs text-[#EAEAE5] truncate">
|
||||
{user?.display_name ?? user?.email ?? 'User'}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-[rgba(234,234,229,0.4)] uppercase tracking-wider">
|
||||
{user?.role ?? 'unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-1.5 rounded transition-colors hover:bg-[rgba(234,234,229,0.1)]"
|
||||
title={t('common.signOut')}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="rgba(234,234,229,0.4)" strokeWidth="1.5">
|
||||
<path d="M6 2H3v12h3M11 4l4 4-4 4M7 8h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AgentStatus, KeyStatus, ExportStatus } from '@elydora/shared';
|
||||
|
||||
type Status = AgentStatus | KeyStatus | ExportStatus | 'running' | 'queued' | 'done' | 'failed' | 'verified' | 'invalid';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const statusConfig: Record<Status, { color: string; dotColor: string; label: string }> = {
|
||||
active: {
|
||||
color: 'text-ink',
|
||||
dotColor: 'bg-ink',
|
||||
label: 'Active',
|
||||
},
|
||||
frozen: {
|
||||
color: 'text-blue-600',
|
||||
dotColor: 'bg-blue-500',
|
||||
label: 'Frozen',
|
||||
},
|
||||
revoked: {
|
||||
color: 'text-red-600',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Revoked',
|
||||
},
|
||||
retired: {
|
||||
color: 'text-ink-dim',
|
||||
dotColor: 'bg-ink-dim',
|
||||
label: 'Retired',
|
||||
},
|
||||
queued: {
|
||||
color: 'text-ink-dim',
|
||||
dotColor: 'bg-ink-dim',
|
||||
label: 'Queued',
|
||||
},
|
||||
running: {
|
||||
color: 'text-amber-600',
|
||||
dotColor: 'bg-amber-500',
|
||||
label: 'Running',
|
||||
},
|
||||
done: {
|
||||
color: 'text-green-600',
|
||||
dotColor: 'bg-green-500',
|
||||
label: 'Done',
|
||||
},
|
||||
failed: {
|
||||
color: 'text-red-600',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Failed',
|
||||
},
|
||||
verified: {
|
||||
color: 'text-green-600',
|
||||
dotColor: 'bg-green-500',
|
||||
label: 'Verified',
|
||||
},
|
||||
invalid: {
|
||||
color: 'text-red-600',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Invalid',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
|
||||
const config = statusConfig[status] ?? {
|
||||
color: 'text-ink-dim',
|
||||
dotColor: 'bg-ink-dim',
|
||||
label: status,
|
||||
};
|
||||
|
||||
const dotSize = size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2';
|
||||
const textSize = size === 'sm' ? 'text-[10px]' : 'text-[11px]';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 ${config.color}`}>
|
||||
<span
|
||||
className={`${dotSize} rounded-full ${config.dotColor} ${
|
||||
status === 'running' ? 'pulse-dot' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className={`font-mono ${textSize} font-medium uppercase tracking-wider`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import en from './en.json';
|
||||
import zh from './zh.json';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"common": {
|
||||
"dashboard": "Dashboard",
|
||||
"agents": "Agents",
|
||||
"operations": "Operations",
|
||||
"epochs": "Epochs",
|
||||
"auditTrail": "Audit Trail",
|
||||
"exports": "Exports",
|
||||
"jwks": "JWKS",
|
||||
"settings": "Settings",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"download": "Download",
|
||||
"continue": "Continue",
|
||||
"signOut": "Sign Out",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"clickToCopy": "Click to copy",
|
||||
"viewAll": "View All",
|
||||
"home": "Home",
|
||||
"docs": "Docs",
|
||||
"console": "Console",
|
||||
"loading": "Loading...",
|
||||
"unknownError": "Unknown error.",
|
||||
"allData": "All data",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyJwks": "Copy JWKS",
|
||||
"links": "LINKS"
|
||||
},
|
||||
"sidebar": {
|
||||
"overview": "OVERVIEW",
|
||||
"management": "MANAGEMENT",
|
||||
"compliance": "COMPLIANCE",
|
||||
"system": "SYSTEM"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "Elydora accountability console overview",
|
||||
"totalAgents": "Total Agents",
|
||||
"registeredAgents": "Registered agents",
|
||||
"totalOperations": "Total Operations",
|
||||
"allRecordedOps": "All recorded operations",
|
||||
"activeEpochs": "Active Epochs",
|
||||
"merkleRollups": "Merkle rollup epochs",
|
||||
"pendingExports": "Pending Exports",
|
||||
"queuedOrRunning": "Queued or running",
|
||||
"quickActions": "Quick Actions",
|
||||
"registerAgent": "Register Agent",
|
||||
"viewOperations": "View Operations",
|
||||
"createExport": "Create Export",
|
||||
"recentOperations": "Recent Operations",
|
||||
"noOperations": "No operations recorded yet.",
|
||||
"agentSummary": "Agent Summary",
|
||||
"noAgentsRegistered": "No agents registered yet. Register an agent to start recording operations.",
|
||||
"latestActivity": "Latest Activity",
|
||||
"failedToLoad": "Failed to load dashboard data. Ensure the API is reachable.",
|
||||
"colOperationId": "Operation ID",
|
||||
"colAgent": "Agent",
|
||||
"colType": "Type",
|
||||
"colIssued": "Issued",
|
||||
"colSeq": "Seq"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
"subtitle": "Manage registered AI agents",
|
||||
"registerAgent": "Register Agent",
|
||||
"searchPlaceholder": "Search agents by ID...",
|
||||
"colAgent": "Agent",
|
||||
"colStatus": "Status",
|
||||
"colOperations": "Operations",
|
||||
"colLatestActivity": "Latest Activity",
|
||||
"emptyMessage": "No agents found. Register an agent to get started.",
|
||||
"failedToLoad": "Failed to load agents.",
|
||||
"deleteAgent": "Delete Agent",
|
||||
"deleteConfirmPre": "Are you sure you want to delete agent ",
|
||||
"deleteConfirmPost": "? This will permanently remove the agent and all its keys. This action cannot be undone.",
|
||||
"deleting": "Deleting...",
|
||||
"failedToDelete": "Failed to delete agent."
|
||||
},
|
||||
"agentDetail": {
|
||||
"title": "Agent Details",
|
||||
"agentId": "Agent ID",
|
||||
"status": "Status",
|
||||
"organization": "Organization",
|
||||
"displayName": "Display Name",
|
||||
"responsibleEntity": "Responsible Entity",
|
||||
"created": "Created",
|
||||
"keys": "Keys",
|
||||
"noKeys": "No keys registered for this agent.",
|
||||
"colKeyId": "Key ID",
|
||||
"colAlgorithm": "Algorithm",
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Created",
|
||||
"revoke": "Revoke",
|
||||
"operations": "Operations",
|
||||
"freezeAgent": "Freeze Agent",
|
||||
"unfreezeAgent": "Unfreeze Agent",
|
||||
"freezeDescPre": "Freezing agent ",
|
||||
"freezeDescPost": " will prevent it from submitting new operations. This action is recorded in the audit trail.",
|
||||
"unfreezeDescPre": "Unfreezing agent ",
|
||||
"unfreezeDescPost": " will allow it to resume submitting operations. This action is recorded in the audit trail.",
|
||||
"reason": "Reason",
|
||||
"freezePlaceholder": "Provide a reason for freezing this agent...",
|
||||
"unfreezePlaceholder": "Provide a reason for unfreezing this agent...",
|
||||
"freezing": "Freezing...",
|
||||
"confirmFreeze": "Confirm Freeze",
|
||||
"unfreezing": "Unfreezing...",
|
||||
"confirmUnfreeze": "Confirm Unfreeze",
|
||||
"failedToFreeze": "Failed to freeze agent.",
|
||||
"failedToUnfreeze": "Failed to unfreeze agent.",
|
||||
"reasonRequired": "A reason is required.",
|
||||
"revokeKey": "Revoke Key",
|
||||
"revokeKeyDescPre": "Revoking key ",
|
||||
"revokeKeyDescPost": " will prevent it from being used to sign operations. This action cannot be undone.",
|
||||
"revokePlaceholder": "Provide a reason for revoking this key...",
|
||||
"revoking": "Revoking...",
|
||||
"confirmRevoke": "Confirm Revoke",
|
||||
"failedToRevoke": "Failed to revoke key.",
|
||||
"failedToLoad": "Failed to load agent.",
|
||||
"mayNotExist": "The agent may not exist.",
|
||||
"noOperations": "No operations recorded for this agent."
|
||||
},
|
||||
"operationDetail": {
|
||||
"title": "Operation Details",
|
||||
"failedToLoad": "Failed to load operation.",
|
||||
"mayNotExist": "The operation may not exist."
|
||||
},
|
||||
"operationCard": {
|
||||
"operationRecord": "Operation Record",
|
||||
"formatted": "Formatted",
|
||||
"rawJson": "Raw JSON",
|
||||
"operationId": "Operation ID",
|
||||
"agentId": "Agent ID",
|
||||
"orgId": "Org ID",
|
||||
"type": "Type",
|
||||
"issuedAt": "Issued At",
|
||||
"createdAt": "Created At",
|
||||
"ttlMs": "TTL (ms)",
|
||||
"nonce": "Nonce",
|
||||
"seqNo": "Seq No",
|
||||
"signingKey": "Signing Key",
|
||||
"payloadHash": "Payload Hash",
|
||||
"prevChainHash": "Prev Chain Hash",
|
||||
"chainHash": "Chain Hash",
|
||||
"r2PayloadKey": "R2 Payload Key",
|
||||
"payload": "Payload",
|
||||
"receipt": "Receipt (EAR)",
|
||||
"receiptId": "Receipt ID",
|
||||
"r2ReceiptKey": "R2 Receipt Key"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
"subtitle": "Browse and inspect recorded operations",
|
||||
"searchPlaceholder": "Search by ID, agent, or type...",
|
||||
"colOperationId": "Operation ID",
|
||||
"colAgent": "Agent",
|
||||
"colType": "Type",
|
||||
"colIssuedAt": "Issued At",
|
||||
"colSeq": "Seq",
|
||||
"agentIdFilter": "Agent ID",
|
||||
"filterByAgent": "Filter by agent",
|
||||
"typeFilter": "Type",
|
||||
"filterByType": "Filter by type",
|
||||
"emptyMessage": "No operations found matching your filters.",
|
||||
"failedToLoad": "Failed to load operations."
|
||||
},
|
||||
"epochs": {
|
||||
"title": "Epochs",
|
||||
"subtitle": "Periodic Merkle-tree rollups anchoring operations",
|
||||
"colEpochId": "Epoch ID",
|
||||
"colTimeRange": "Time Range",
|
||||
"colLeaves": "Leaves",
|
||||
"colRootHash": "Root Hash",
|
||||
"colCreated": "Created",
|
||||
"emptyMessage": "No epochs have been created yet. Epochs are generated periodically as operations are recorded.",
|
||||
"failedToLoad": "Failed to load epochs."
|
||||
},
|
||||
"epochDetail": {
|
||||
"title": "Epoch Details",
|
||||
"viewOperations": "View Operations",
|
||||
"epochMetadata": "Epoch Metadata",
|
||||
"epochId": "Epoch ID",
|
||||
"organization": "Organization",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"duration": "Duration",
|
||||
"durationMinutes": "{{count}} minutes",
|
||||
"createdAt": "Created At",
|
||||
"merkleTree": "Merkle Tree",
|
||||
"rootHash": "Root Hash",
|
||||
"leafCount": "Leaf Count",
|
||||
"operationsUnit": "operations",
|
||||
"tsaAnchor": "TSA Anchor",
|
||||
"tsaToken": "TSA Token",
|
||||
"tsaUrl": "TSA URL",
|
||||
"anchoredAt": "Anchored At",
|
||||
"noTsaAnchor": "No TSA anchor information available for this epoch.",
|
||||
"storage": "Storage",
|
||||
"r2EpochKey": "R2 Epoch Key",
|
||||
"failedToLoad": "Failed to load epoch.",
|
||||
"mayNotExist": "The epoch may not exist."
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit Trail",
|
||||
"subtitle": "Query and inspect the full audit log of operations",
|
||||
"exportData": "Export Data",
|
||||
"queryBuilder": "Query Builder",
|
||||
"clearFilters": "Clear Filters",
|
||||
"agentId": "Agent ID",
|
||||
"filterByAgent": "Filter by agent",
|
||||
"operationType": "Operation Type",
|
||||
"filterByType": "Filter by type",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"showing": "Showing {{shown}} of {{total}} results",
|
||||
"moreResults": "More results available",
|
||||
"colOperationId": "Operation ID",
|
||||
"colAgent": "Agent",
|
||||
"colType": "Type",
|
||||
"colIssuedAt": "Issued At",
|
||||
"colSeq": "Seq",
|
||||
"colChainHash": "Chain Hash",
|
||||
"emptyMessage": "No audit records match your query. Adjust your filters or clear them to see all records.",
|
||||
"failedToLoad": "Failed to load audit data."
|
||||
},
|
||||
"exports": {
|
||||
"title": "Exports",
|
||||
"subtitle": "Create and download compliance data exports",
|
||||
"createExport": "Create Export",
|
||||
"colExportId": "Export ID",
|
||||
"colStatus": "Status",
|
||||
"colFilters": "Filters",
|
||||
"colCreated": "Created",
|
||||
"colCompleted": "Completed",
|
||||
"emptyMessage": "No exports have been created yet. Create an export to download compliance data.",
|
||||
"failedToLoad": "Failed to load exports.",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"agentIdOptional": "Agent ID (optional)",
|
||||
"filterByAgent": "Filter by specific agent",
|
||||
"operationTypeOptional": "Operation Type (optional)",
|
||||
"filterByOperationType": "Filter by operation type",
|
||||
"format": "Format",
|
||||
"creating": "Creating...",
|
||||
"startEndRequired": "Start time and end time are required.",
|
||||
"failedToCreate": "Failed to create export."
|
||||
},
|
||||
"jwks": {
|
||||
"title": "JWKS",
|
||||
"subtitle": "JSON Web Key Set for token verification",
|
||||
"publicKeys": "Public Keys",
|
||||
"colKeyId": "Key ID",
|
||||
"colKeyType": "Key Type",
|
||||
"colAlgorithm": "Algorithm",
|
||||
"colCurve": "Curve",
|
||||
"colUse": "Use",
|
||||
"colPublicKey": "Public Key",
|
||||
"emptyMessage": "No JWKS keys available. The server may not have published any public keys.",
|
||||
"failedToLoad": "Failed to load JWKS.",
|
||||
"endpoint": "JWKS endpoint:"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Account and organization details",
|
||||
"user": "User",
|
||||
"displayName": "Display Name",
|
||||
"email": "Email",
|
||||
"subject": "Subject",
|
||||
"role": "Role",
|
||||
"organization": "Organization",
|
||||
"organizationId": "Organization ID"
|
||||
},
|
||||
"login": {
|
||||
"signIn": "Sign in",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "Password",
|
||||
"signingIn": "Signing in...",
|
||||
"submitSignIn": "Sign In",
|
||||
"noAccount": "No account? ",
|
||||
"createOne": "Create one",
|
||||
"loginFailed": "Login failed"
|
||||
},
|
||||
"register": {
|
||||
"createAccount": "Create account",
|
||||
"orgName": "Organization Name",
|
||||
"orgPlaceholder": "Acme Corp",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "Jane Smith",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Minimum 8 characters",
|
||||
"creatingAccount": "Creating account...",
|
||||
"submitCreateAccount": "Create Account",
|
||||
"hasAccount": "Already have an account? ",
|
||||
"signIn": "Sign in",
|
||||
"registrationFailed": "Registration failed"
|
||||
},
|
||||
"agentRegistration": {
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "My AI Agent",
|
||||
"displayNameRequired": "Display Name is required.",
|
||||
"responsibleEntity": "Responsible Entity (optional)",
|
||||
"responsibleEntityPlaceholder": "Person or team responsible",
|
||||
"submitting": "Registering...",
|
||||
"submit": "Register Agent",
|
||||
"failedToRegister": "Failed to register agent.",
|
||||
"agentRegistered": "Agent Registered",
|
||||
"privateKeySaveNow": "Private Key — Save This Now",
|
||||
"privateKeyWarning": "This will not be shown again. Store it as an environment variable or in a secrets manager.",
|
||||
"apiToken": "API Token",
|
||||
"apiTokenDesc": "Use this as your ELYDORA_TOKEN environment variable. This is your bearer token for API authentication.",
|
||||
"tokenExpiration": "Token Expiration",
|
||||
"customDays": "Number of days",
|
||||
"days": "days",
|
||||
"enterValidDays": "Enter a valid number of days (minimum 1).",
|
||||
"issuing": "Issuing...",
|
||||
"issueApiToken": "Issue API Token",
|
||||
"failedToIssueToken": "Failed to issue token.",
|
||||
"credAgentId": "Agent ID",
|
||||
"credKeyId": "Key ID",
|
||||
"credOrgId": "Org ID",
|
||||
"credAlgorithm": "Algorithm",
|
||||
"chooseIntegration": "Choose Integration",
|
||||
"chooseIntegrationDesc": "How will this agent connect to Elydora?",
|
||||
"supported": "Supported (one-command install)",
|
||||
"sdkIntegration": "SDK Integration",
|
||||
"install": "Install",
|
||||
"backToList": "Back",
|
||||
"sdkTutorial": "SDK Tutorial",
|
||||
"aiReadyPrompt": "AI-Ready Prompt",
|
||||
"aiReadyPromptDesc": "Copy this prompt and paste it into your AI agent to set up Elydora automatically.",
|
||||
"copyPrompt": "Copy Prompt",
|
||||
"done": "Done",
|
||||
"step": "Step {{current}} of {{total}}"
|
||||
},
|
||||
"chain": {
|
||||
"title": "Chain Visualization",
|
||||
"previous": "Previous",
|
||||
"current": "Current ({{id}})",
|
||||
"next": "Next",
|
||||
"pending": "Pending...",
|
||||
"chainBlock": "Chain block",
|
||||
"currentOperation": "Current operation"
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verification",
|
||||
"verify": "Verify",
|
||||
"reVerify": "Re-verify",
|
||||
"verifying": "Verifying...",
|
||||
"verifiedSuccess": "Operation verified successfully",
|
||||
"verificationFailed": "Verification failed",
|
||||
"signature": "Signature",
|
||||
"signatureDesc": "Ed25519 signature is valid for the given public key",
|
||||
"chainCheck": "Chain",
|
||||
"chainCheckDesc": "Previous chain hash matches the expected value",
|
||||
"receiptCheck": "Receipt",
|
||||
"receiptCheckDesc": "Server receipt (EAR) is valid and matches the operation",
|
||||
"merkle": "Merkle",
|
||||
"merkleDesc": "Operation is included in the epoch Merkle tree",
|
||||
"pending": "Pending",
|
||||
"passed": "Passed",
|
||||
"failed": "Failed",
|
||||
"errors": "Errors"
|
||||
},
|
||||
"tokenExpiration": {
|
||||
"24hours": "24 hours",
|
||||
"7days": "7 days",
|
||||
"1month": "1 month",
|
||||
"1year": "1 year",
|
||||
"custom": "Custom",
|
||||
"neverExpire": "Never expire"
|
||||
},
|
||||
"integrations": {
|
||||
"claudecode": "Anthropic CLI agent",
|
||||
"cursor": "AI-powered code editor",
|
||||
"gemini": "Google AI CLI agent",
|
||||
"kirocli": "AWS AI CLI agent",
|
||||
"kiroide": "AWS spec-driven AI IDE",
|
||||
"opencode": "Open-source TUI agent",
|
||||
"copilot": "GitHub Copilot CLI agent",
|
||||
"letta": "Letta AI CLI agent",
|
||||
"codex": "OpenAI CLI agent",
|
||||
"kimi": "Moonshot AI assistant",
|
||||
"enterprise": "Your own internal agent",
|
||||
"gui": "Desktop or web-based agent",
|
||||
"other": "Any custom integration"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"common": {
|
||||
"dashboard": "仪表盘",
|
||||
"agents": "代理",
|
||||
"operations": "操作",
|
||||
"epochs": "纪元",
|
||||
"auditTrail": "审计追踪",
|
||||
"exports": "导出",
|
||||
"jwks": "JWKS",
|
||||
"settings": "设置",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"download": "下载",
|
||||
"continue": "继续",
|
||||
"signOut": "退出登录",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"clickToCopy": "点击复制",
|
||||
"viewAll": "查看全部",
|
||||
"home": "首页",
|
||||
"docs": "文档",
|
||||
"console": "控制台",
|
||||
"loading": "加载中...",
|
||||
"unknownError": "未知错误。",
|
||||
"allData": "全部数据",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"copyJwks": "复制 JWKS",
|
||||
"links": "链接"
|
||||
},
|
||||
"sidebar": {
|
||||
"overview": "概览",
|
||||
"management": "管理",
|
||||
"compliance": "合规",
|
||||
"system": "系统"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"subtitle": "Elydora 责任追溯控制台概览",
|
||||
"totalAgents": "代理总数",
|
||||
"registeredAgents": "已注册代理",
|
||||
"totalOperations": "操作总数",
|
||||
"allRecordedOps": "所有已记录操作",
|
||||
"activeEpochs": "活跃纪元",
|
||||
"merkleRollups": "Merkle 树汇总纪元",
|
||||
"pendingExports": "待处理导出",
|
||||
"queuedOrRunning": "排队中或运行中",
|
||||
"quickActions": "快捷操作",
|
||||
"registerAgent": "注册代理",
|
||||
"viewOperations": "查看操作",
|
||||
"createExport": "创建导出",
|
||||
"recentOperations": "最近操作",
|
||||
"noOperations": "尚无操作记录。",
|
||||
"agentSummary": "代理概要",
|
||||
"noAgentsRegistered": "尚未注册代理。注册一个代理以开始记录操作。",
|
||||
"latestActivity": "最近活动",
|
||||
"failedToLoad": "加载仪表盘数据失败。请确保 API 可访问。",
|
||||
"colOperationId": "操作 ID",
|
||||
"colAgent": "代理",
|
||||
"colType": "类型",
|
||||
"colIssued": "签发时间",
|
||||
"colSeq": "序号"
|
||||
},
|
||||
"agents": {
|
||||
"title": "代理",
|
||||
"subtitle": "管理已注册的 AI 代理",
|
||||
"registerAgent": "注册代理",
|
||||
"searchPlaceholder": "按 ID 搜索代理...",
|
||||
"colAgent": "代理",
|
||||
"colStatus": "状态",
|
||||
"colOperations": "操作数",
|
||||
"colLatestActivity": "最近活动",
|
||||
"emptyMessage": "未找到代理。注册一个代理以开始使用。",
|
||||
"failedToLoad": "加载代理列表失败。",
|
||||
"deleteAgent": "删除代理",
|
||||
"deleteConfirmPre": "确定要删除代理 ",
|
||||
"deleteConfirmPost": " 吗?这将永久移除该代理及其所有密钥。此操作无法撤销。",
|
||||
"deleting": "删除中...",
|
||||
"failedToDelete": "删除代理失败。"
|
||||
},
|
||||
"agentDetail": {
|
||||
"title": "代理详情",
|
||||
"agentId": "代理 ID",
|
||||
"status": "状态",
|
||||
"organization": "组织",
|
||||
"displayName": "显示名称",
|
||||
"responsibleEntity": "责任实体",
|
||||
"created": "创建时间",
|
||||
"keys": "密钥",
|
||||
"noKeys": "该代理没有已注册的密钥。",
|
||||
"colKeyId": "密钥 ID",
|
||||
"colAlgorithm": "算法",
|
||||
"colStatus": "状态",
|
||||
"colCreated": "创建时间",
|
||||
"revoke": "吊销",
|
||||
"operations": "操作",
|
||||
"freezeAgent": "冻结代理",
|
||||
"unfreezeAgent": "解冻代理",
|
||||
"reason": "原因",
|
||||
"freezePlaceholder": "请提供冻结此代理的原因...",
|
||||
"unfreezePlaceholder": "请提供解冻此代理的原因...",
|
||||
"freezing": "冻结中...",
|
||||
"confirmFreeze": "确认冻结",
|
||||
"unfreezing": "解冻中...",
|
||||
"confirmUnfreeze": "确认解冻",
|
||||
"failedToFreeze": "冻结代理失败。",
|
||||
"failedToUnfreeze": "解冻代理失败。",
|
||||
"reasonRequired": "原因为必填项。",
|
||||
"revokeKey": "吊销密钥",
|
||||
"revokePlaceholder": "请提供吊销此密钥的原因...",
|
||||
"revoking": "吊销中...",
|
||||
"confirmRevoke": "确认吊销",
|
||||
"failedToRevoke": "吊销密钥失败。",
|
||||
"freezeDescPre": "冻结代理 ",
|
||||
"freezeDescPost": " 将阻止其提交新操作。此操作会记录在审计追踪中。",
|
||||
"unfreezeDescPre": "解冻代理 ",
|
||||
"unfreezeDescPost": " 将允许其恢复提交操作。此操作会记录在审计追踪中。",
|
||||
"revokeKeyDescPre": "吊销密钥 ",
|
||||
"revokeKeyDescPost": " 将阻止其用于签署操作。此操作无法撤销。",
|
||||
"failedToLoad": "加载代理信息失败。",
|
||||
"mayNotExist": "该代理可能不存在。",
|
||||
"noOperations": "该代理尚无操作记录。"
|
||||
},
|
||||
"operationDetail": {
|
||||
"title": "操作详情",
|
||||
"failedToLoad": "加载操作信息失败。",
|
||||
"mayNotExist": "该操作可能不存在。"
|
||||
},
|
||||
"operationCard": {
|
||||
"operationRecord": "操作记录",
|
||||
"formatted": "格式化",
|
||||
"rawJson": "原始 JSON",
|
||||
"operationId": "操作 ID",
|
||||
"agentId": "代理 ID",
|
||||
"orgId": "组织 ID",
|
||||
"type": "类型",
|
||||
"issuedAt": "签发时间",
|
||||
"createdAt": "创建时间",
|
||||
"ttlMs": "TTL (ms)",
|
||||
"nonce": "Nonce",
|
||||
"seqNo": "序号",
|
||||
"signingKey": "签名密钥",
|
||||
"payloadHash": "载荷哈希",
|
||||
"prevChainHash": "上一链哈希",
|
||||
"chainHash": "链哈希",
|
||||
"r2PayloadKey": "R2 载荷键",
|
||||
"payload": "载荷",
|
||||
"receipt": "回执 (EAR)",
|
||||
"receiptId": "回执 ID",
|
||||
"r2ReceiptKey": "R2 回执键"
|
||||
},
|
||||
"operations": {
|
||||
"title": "操作",
|
||||
"subtitle": "浏览和检查已记录的操作",
|
||||
"searchPlaceholder": "按 ID、代理或类型搜索...",
|
||||
"colOperationId": "操作 ID",
|
||||
"colAgent": "代理",
|
||||
"colType": "类型",
|
||||
"colIssuedAt": "签发时间",
|
||||
"colSeq": "序号",
|
||||
"agentIdFilter": "代理 ID",
|
||||
"filterByAgent": "按代理筛选",
|
||||
"typeFilter": "类型",
|
||||
"filterByType": "按类型筛选",
|
||||
"emptyMessage": "没有符合筛选条件的操作。",
|
||||
"failedToLoad": "加载操作列表失败。"
|
||||
},
|
||||
"epochs": {
|
||||
"title": "纪元",
|
||||
"subtitle": "定期 Merkle 树汇总锚定操作",
|
||||
"colEpochId": "纪元 ID",
|
||||
"colTimeRange": "时间范围",
|
||||
"colLeaves": "叶子数",
|
||||
"colRootHash": "根哈希",
|
||||
"colCreated": "创建时间",
|
||||
"emptyMessage": "尚未创建纪元。纪元会随着操作的记录定期生成。",
|
||||
"failedToLoad": "加载纪元列表失败。"
|
||||
},
|
||||
"epochDetail": {
|
||||
"title": "纪元详情",
|
||||
"viewOperations": "查看操作",
|
||||
"epochMetadata": "纪元元数据",
|
||||
"epochId": "纪元 ID",
|
||||
"organization": "组织",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"duration": "持续时间",
|
||||
"durationMinutes": "{{count}} 分钟",
|
||||
"createdAt": "创建时间",
|
||||
"merkleTree": "Merkle 树",
|
||||
"rootHash": "根哈希",
|
||||
"leafCount": "叶子数量",
|
||||
"operationsUnit": "个操作",
|
||||
"tsaAnchor": "TSA 锚点",
|
||||
"tsaToken": "TSA 令牌",
|
||||
"tsaUrl": "TSA URL",
|
||||
"anchoredAt": "锚定时间",
|
||||
"noTsaAnchor": "此纪元暂无 TSA 锚点信息。",
|
||||
"storage": "存储",
|
||||
"r2EpochKey": "R2 纪元键",
|
||||
"failedToLoad": "加载纪元信息失败。",
|
||||
"mayNotExist": "该纪元可能不存在。"
|
||||
},
|
||||
"audit": {
|
||||
"title": "审计追踪",
|
||||
"subtitle": "查询和检查完整的操作审计日志",
|
||||
"exportData": "导出数据",
|
||||
"queryBuilder": "查询构建器",
|
||||
"clearFilters": "清除筛选",
|
||||
"agentId": "代理 ID",
|
||||
"filterByAgent": "按代理筛选",
|
||||
"operationType": "操作类型",
|
||||
"filterByType": "按类型筛选",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"showing": "显示 {{shown}} / {{total}} 条结果",
|
||||
"moreResults": "还有更多结果",
|
||||
"colOperationId": "操作 ID",
|
||||
"colAgent": "代理",
|
||||
"colType": "类型",
|
||||
"colIssuedAt": "签发时间",
|
||||
"colSeq": "序号",
|
||||
"colChainHash": "链哈希",
|
||||
"emptyMessage": "没有匹配的审计记录。请调整筛选条件或清除筛选以查看所有记录。",
|
||||
"failedToLoad": "加载审计数据失败。"
|
||||
},
|
||||
"exports": {
|
||||
"title": "导出",
|
||||
"subtitle": "创建和下载合规数据导出",
|
||||
"createExport": "创建导出",
|
||||
"colExportId": "导出 ID",
|
||||
"colStatus": "状态",
|
||||
"colFilters": "筛选条件",
|
||||
"colCreated": "创建时间",
|
||||
"colCompleted": "完成时间",
|
||||
"emptyMessage": "尚未创建导出。创建导出以下载合规数据。",
|
||||
"failedToLoad": "加载导出列表失败。",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"agentIdOptional": "代理 ID(可选)",
|
||||
"filterByAgent": "按特定代理筛选",
|
||||
"operationTypeOptional": "操作类型(可选)",
|
||||
"filterByOperationType": "按操作类型筛选",
|
||||
"format": "格式",
|
||||
"creating": "创建中...",
|
||||
"startEndRequired": "开始时间和结束时间为必填项。",
|
||||
"failedToCreate": "创建导出失败。"
|
||||
},
|
||||
"jwks": {
|
||||
"title": "JWKS",
|
||||
"subtitle": "用于令牌验证的 JSON Web 密钥集",
|
||||
"publicKeys": "公钥",
|
||||
"colKeyId": "密钥 ID",
|
||||
"colKeyType": "密钥类型",
|
||||
"colAlgorithm": "算法",
|
||||
"colCurve": "曲线",
|
||||
"colUse": "用途",
|
||||
"colPublicKey": "公钥",
|
||||
"emptyMessage": "没有可用的 JWKS 密钥。服务器可能尚未发布公钥。",
|
||||
"failedToLoad": "加载 JWKS 失败。",
|
||||
"endpoint": "JWKS 端点:"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"subtitle": "账户和组织详情",
|
||||
"user": "用户",
|
||||
"displayName": "显示名称",
|
||||
"email": "邮箱",
|
||||
"subject": "主体",
|
||||
"role": "角色",
|
||||
"organization": "组织",
|
||||
"organizationId": "组织 ID"
|
||||
},
|
||||
"login": {
|
||||
"signIn": "登录",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "密码",
|
||||
"signingIn": "登录中...",
|
||||
"submitSignIn": "登录",
|
||||
"noAccount": "还没有账户?",
|
||||
"createOne": "创建一个",
|
||||
"loginFailed": "登录失败"
|
||||
},
|
||||
"register": {
|
||||
"createAccount": "创建账户",
|
||||
"orgName": "组织名称",
|
||||
"orgPlaceholder": "示例公司",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "张三",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "至少 8 个字符",
|
||||
"creatingAccount": "创建账户中...",
|
||||
"submitCreateAccount": "创建账户",
|
||||
"hasAccount": "已有账户?",
|
||||
"signIn": "登录",
|
||||
"registrationFailed": "注册失败"
|
||||
},
|
||||
"agentRegistration": {
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "我的 AI 代理",
|
||||
"displayNameRequired": "显示名称为必填项。",
|
||||
"responsibleEntity": "责任实体(可选)",
|
||||
"responsibleEntityPlaceholder": "负责人或团队",
|
||||
"submitting": "注册中...",
|
||||
"submit": "注册代理",
|
||||
"failedToRegister": "注册代理失败。",
|
||||
"agentRegistered": "代理注册成功",
|
||||
"privateKeySaveNow": "私钥 — 请立即保存",
|
||||
"privateKeyWarning": "此私钥不会再次显示。请将其存储为环境变量或保存在密钥管理器中。",
|
||||
"apiToken": "API 令牌",
|
||||
"apiTokenDesc": "将其用作 ELYDORA_TOKEN 环境变量。这是您的 API 认证令牌。",
|
||||
"tokenExpiration": "令牌有效期",
|
||||
"customDays": "天数",
|
||||
"days": "天",
|
||||
"enterValidDays": "请输入有效的天数(最少 1 天)。",
|
||||
"issuing": "签发中...",
|
||||
"issueApiToken": "签发 API 令牌",
|
||||
"failedToIssueToken": "签发令牌失败。",
|
||||
"credAgentId": "代理 ID",
|
||||
"credKeyId": "密钥 ID",
|
||||
"credOrgId": "组织 ID",
|
||||
"credAlgorithm": "算法",
|
||||
"chooseIntegration": "选择集成方式",
|
||||
"chooseIntegrationDesc": "此代理将如何连接到 Elydora?",
|
||||
"supported": "支持的(一键安装)",
|
||||
"sdkIntegration": "SDK 集成",
|
||||
"install": "安装",
|
||||
"backToList": "返回",
|
||||
"sdkTutorial": "SDK 教程",
|
||||
"aiReadyPrompt": "AI 就绪提示",
|
||||
"aiReadyPromptDesc": "复制此提示并粘贴到您的 AI 代理中以自动设置 Elydora。",
|
||||
"copyPrompt": "复制提示",
|
||||
"done": "完成",
|
||||
"step": "第 {{current}} 步,共 {{total}} 步"
|
||||
},
|
||||
"chain": {
|
||||
"title": "链可视化",
|
||||
"previous": "上一个",
|
||||
"current": "当前 ({{id}})",
|
||||
"next": "下一个",
|
||||
"pending": "待处理...",
|
||||
"chainBlock": "链块",
|
||||
"currentOperation": "当前操作"
|
||||
},
|
||||
"verification": {
|
||||
"title": "验证",
|
||||
"verify": "验证",
|
||||
"reVerify": "重新验证",
|
||||
"verifying": "验证中...",
|
||||
"verifiedSuccess": "操作验证成功",
|
||||
"verificationFailed": "验证失败",
|
||||
"signature": "签名",
|
||||
"signatureDesc": "Ed25519 签名对给定公钥有效",
|
||||
"chainCheck": "链",
|
||||
"chainCheckDesc": "上一链哈希与预期值匹配",
|
||||
"receiptCheck": "回执",
|
||||
"receiptCheckDesc": "服务器回执 (EAR) 有效且与操作匹配",
|
||||
"merkle": "Merkle",
|
||||
"merkleDesc": "操作包含在纪元 Merkle 树中",
|
||||
"pending": "待验证",
|
||||
"passed": "通过",
|
||||
"failed": "失败",
|
||||
"errors": "错误"
|
||||
},
|
||||
"tokenExpiration": {
|
||||
"24hours": "24 小时",
|
||||
"7days": "7 天",
|
||||
"1month": "1 个月",
|
||||
"1year": "1 年",
|
||||
"custom": "自定义",
|
||||
"neverExpire": "永不过期"
|
||||
},
|
||||
"integrations": {
|
||||
"claudecode": "Anthropic CLI 代理",
|
||||
"cursor": "AI 驱动的代码编辑器",
|
||||
"gemini": "Google AI CLI 代理",
|
||||
"kirocli": "AWS AI CLI 代理",
|
||||
"kiroide": "AWS 规格驱动的 AI IDE",
|
||||
"opencode": "开源 TUI 代理",
|
||||
"copilot": "GitHub Copilot CLI 代理",
|
||||
"letta": "Letta AI CLI 代理",
|
||||
"codex": "OpenAI CLI 代理",
|
||||
"kimi": "Moonshot AI 助手",
|
||||
"enterprise": "您的内部代理",
|
||||
"gui": "桌面或 Web 代理",
|
||||
"other": "任何自定义集成"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type {
|
||||
Agent,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
FreezeAgentRequest,
|
||||
UnfreezeAgentRequest,
|
||||
RevokeAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
SubmitOperationRequest,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
AuthLoginResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthMeResponse,
|
||||
AuthRefreshResponse,
|
||||
IssueTokenResponse,
|
||||
JWKSResponse,
|
||||
ErrorResponse,
|
||||
} from '@elydora/shared';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
public readonly requestId: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('elydora_token');
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> ?? {}),
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody: ErrorResponse | null = null;
|
||||
try {
|
||||
errorBody = await response.json() as ErrorResponse;
|
||||
} catch {
|
||||
// Response body is not JSON
|
||||
}
|
||||
|
||||
if (errorBody?.error) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
errorBody.error.code,
|
||||
errorBody.error.request_id,
|
||||
errorBody.error.message,
|
||||
errorBody.error.details,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
'INTERNAL_ERROR',
|
||||
'unknown',
|
||||
`Request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
agents: {
|
||||
list(): Promise<ListAgentsResponse> {
|
||||
return request<ListAgentsResponse>('/v1/agents');
|
||||
},
|
||||
|
||||
register(body: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
||||
return request<RegisterAgentResponse>('/v1/agents/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
get(agentId: string): Promise<GetAgentResponse> {
|
||||
return request<GetAgentResponse>(`/v1/agents/${encodeURIComponent(agentId)}`);
|
||||
},
|
||||
|
||||
update(agentId: string, body: UpdateAgentRequest): Promise<{ agent: Agent }> {
|
||||
return request<{ agent: Agent }>(`/v1/agents/${encodeURIComponent(agentId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
freeze(agentId: string, reason: string): Promise<void> {
|
||||
const body: FreezeAgentRequest = { reason };
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/freeze`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
unfreeze(agentId: string, reason: string): Promise<void> {
|
||||
const body: UnfreezeAgentRequest = { reason };
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/unfreeze`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
revokeKey(agentId: string, kid: string, reason: string): Promise<void> {
|
||||
const body: RevokeAgentRequest = { kid, reason };
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/revoke`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
delete(agentId: string): Promise<void> {
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
operations: {
|
||||
submit(eor: SubmitOperationRequest): Promise<SubmitOperationResponse> {
|
||||
return request<SubmitOperationResponse>('/v1/operations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(eor),
|
||||
});
|
||||
},
|
||||
|
||||
get(operationId: string): Promise<GetOperationResponse> {
|
||||
return request<GetOperationResponse>(
|
||||
`/v1/operations/${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
},
|
||||
|
||||
verify(operationId: string): Promise<VerifyOperationResponse> {
|
||||
return request<VerifyOperationResponse>(
|
||||
`/v1/operations/${encodeURIComponent(operationId)}/verify`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
audit: {
|
||||
query(params: AuditQueryRequest): Promise<AuditQueryResponse> {
|
||||
return request<AuditQueryResponse>(`/v1/audit/query`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
epochs: {
|
||||
list(): Promise<{ epochs: Array<{ 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 }> }> {
|
||||
return request(`/v1/epochs`);
|
||||
},
|
||||
|
||||
get(epochId: string): Promise<GetEpochResponse> {
|
||||
return request<GetEpochResponse>(`/v1/epochs/${encodeURIComponent(epochId)}`);
|
||||
},
|
||||
},
|
||||
|
||||
exports: {
|
||||
create(body: CreateExportRequest): Promise<CreateExportResponse> {
|
||||
return request<CreateExportResponse>('/v1/exports', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
get(exportId: string): Promise<GetExportResponse> {
|
||||
return request<GetExportResponse>(`/v1/exports/${encodeURIComponent(exportId)}`);
|
||||
},
|
||||
|
||||
list(): Promise<{ exports: Array<import('@elydora/shared').Export> }> {
|
||||
return request(`/v1/exports`);
|
||||
},
|
||||
|
||||
async download(exportId: string): Promise<Blob> {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/v1/exports/${encodeURIComponent(exportId)}/download`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, 'DOWNLOAD_ERROR', 'unknown', `Download failed with status ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
},
|
||||
|
||||
jwks: {
|
||||
get(): Promise<JWKSResponse> {
|
||||
return request<JWKSResponse>('/.well-known/elydora/jwks.json');
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
login(email: string, password: string): Promise<AuthLoginResponse> {
|
||||
return request<AuthLoginResponse>('/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
},
|
||||
|
||||
register(email: string, password: string, displayName: string, orgName: string): Promise<AuthRegisterResponse> {
|
||||
return request<AuthRegisterResponse>('/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, display_name: displayName, org_name: orgName }),
|
||||
});
|
||||
},
|
||||
|
||||
me(): Promise<AuthMeResponse> {
|
||||
return request<AuthMeResponse>('/v1/auth/me');
|
||||
},
|
||||
|
||||
refresh(): Promise<AuthRefreshResponse> {
|
||||
return request<AuthRefreshResponse>('/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
issueToken(ttlSeconds: number | null): Promise<IssueTokenResponse> {
|
||||
return request<IssueTokenResponse>('/v1/auth/token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ttl_seconds: ttlSeconds }),
|
||||
});
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { ApiError };
|
||||
export type { ErrorResponse };
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { RbacRole } from '@elydora/shared';
|
||||
|
||||
interface AuthUser {
|
||||
sub: string;
|
||||
org_id: string;
|
||||
role: RbacRole;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (token: string, user?: { display_name?: string; email?: string }) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function decodeJWTPayload(token: string): AuthUser | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
// base64url decode
|
||||
let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4 !== 0) base64 += '=';
|
||||
const json = atob(base64);
|
||||
const payload = JSON.parse(json);
|
||||
// Check expiration
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (typeof payload.exp === 'number' && payload.exp < nowSeconds) return null;
|
||||
if (!payload.sub || !payload.org_id || !payload.role) return null;
|
||||
return { sub: payload.sub, org_id: payload.org_id, role: payload.role };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('elydora_token');
|
||||
if (stored) {
|
||||
const decoded = decodeJWTPayload(stored);
|
||||
if (decoded) {
|
||||
setToken(stored);
|
||||
// Try to fetch full user profile from localStorage
|
||||
const displayName = localStorage.getItem('elydora_display_name');
|
||||
const email = localStorage.getItem('elydora_email');
|
||||
if (displayName) decoded.display_name = displayName;
|
||||
if (email) decoded.email = email;
|
||||
setUser({ ...decoded });
|
||||
} else {
|
||||
// Token expired or invalid, clear it
|
||||
localStorage.removeItem('elydora_token');
|
||||
localStorage.removeItem('elydora_display_name');
|
||||
localStorage.removeItem('elydora_email');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback((newToken: string, userInfo?: { display_name?: string; email?: string }) => {
|
||||
localStorage.setItem('elydora_token', newToken);
|
||||
const decoded = decodeJWTPayload(newToken);
|
||||
if (decoded) {
|
||||
if (userInfo?.display_name) {
|
||||
decoded.display_name = userInfo.display_name;
|
||||
localStorage.setItem('elydora_display_name', userInfo.display_name);
|
||||
}
|
||||
if (userInfo?.email) {
|
||||
decoded.email = userInfo.email;
|
||||
localStorage.setItem('elydora_email', userInfo.email);
|
||||
}
|
||||
setUser(decoded);
|
||||
}
|
||||
setToken(newToken);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('elydora_token');
|
||||
localStorage.removeItem('elydora_display_name');
|
||||
localStorage.removeItem('elydora_email');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isAuthenticated: !!user, isLoading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { api } from './api';
|
||||
import type {
|
||||
AuditQueryRequest,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
GetOperationResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
AuditQueryResponse,
|
||||
JWKSResponse,
|
||||
} from '@elydora/shared';
|
||||
|
||||
export function useAgent(agentId: string | undefined) {
|
||||
return useSWR<GetAgentResponse>(
|
||||
agentId ? `/agents/${agentId}` : null,
|
||||
agentId ? () => api.agents.get(agentId) : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgentsList() {
|
||||
return useSWR<ListAgentsResponse>(
|
||||
'/agents',
|
||||
() => api.agents.list(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useOperation(operationId: string | undefined) {
|
||||
return useSWR<GetOperationResponse>(
|
||||
operationId ? `/operations/${operationId}` : null,
|
||||
operationId ? () => api.operations.get(operationId) : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useAudit(params: AuditQueryRequest) {
|
||||
return useSWR<AuditQueryResponse>(
|
||||
['/audit', params],
|
||||
() => api.audit.query(params),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useEpoch(epochId: string | undefined) {
|
||||
return useSWR<GetEpochResponse>(
|
||||
epochId ? `/epochs/${epochId}` : null,
|
||||
epochId ? () => api.epochs.get(epochId) : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useEpochs() {
|
||||
return useSWR<{ epochs: Array<{ 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 }> }>(
|
||||
'/epochs',
|
||||
() => api.epochs.list(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useExport(exportId: string | undefined) {
|
||||
return useSWR<GetExportResponse>(
|
||||
exportId ? `/exports/${exportId}` : null,
|
||||
exportId ? () => api.exports.get(exportId) : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: (data) => {
|
||||
if (data?.export?.status === 'queued' || data?.export?.status === 'running') {
|
||||
return 3000;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useExports() {
|
||||
return useSWR<{ exports: Array<import('@elydora/shared').Export> }>(
|
||||
'/exports',
|
||||
() => api.exports.list(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useJWKS() {
|
||||
return useSWR<JWKSResponse>(
|
||||
'/jwks',
|
||||
() => api.jwks.get(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useFadeIn() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.unobserve(element);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, isVisible };
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { copied, copy };
|
||||
}
|
||||
|
||||
export function formatTimestamp(ms: number): string {
|
||||
return new Date(ms).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(ms: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ms;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
|
||||
export function truncateHash(hash: string, length = 8): string {
|
||||
if (hash.length <= length * 2) return hash;
|
||||
return `${hash.slice(0, length)}...${hash.slice(-length)}`;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@elydora/shared": [
|
||||
"../server/src/shared"
|
||||
],
|
||||
"@elydora/shared/*": [
|
||||
"../server/src/shared/*"
|
||||
]
|
||||
},
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 elydora && adduser --system --uid 1001 elydora
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
USER elydora
|
||||
EXPOSE 8787
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Elydora PostgreSQL Schema
|
||||
-- Migration 001: Combined initial schema (agents, auth, operations, epochs, exports)
|
||||
-- All timestamps are Unix milliseconds (BIGINT)
|
||||
|
||||
-- =========================================================================
|
||||
-- Schema version tracking
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Organizations
|
||||
-- =========================================================================
|
||||
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
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Users
|
||||
-- =========================================================================
|
||||
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',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
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);
|
||||
|
||||
-- =========================================================================
|
||||
-- Agents
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
responsible_entity TEXT NOT NULL,
|
||||
integration_type TEXT NOT NULL DEFAULT 'sdk',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_org_id ON agents (org_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- Agent keys
|
||||
-- =========================================================================
|
||||
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',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
retired_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent_id ON agent_keys (agent_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- Operations
|
||||
-- =========================================================================
|
||||
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 INTEGER NOT NULL,
|
||||
operation_type TEXT NOT NULL,
|
||||
issued_at BIGINT NOT NULL,
|
||||
ttl_ms INTEGER 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);
|
||||
|
||||
-- =========================================================================
|
||||
-- Receipts
|
||||
-- =========================================================================
|
||||
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
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Epochs
|
||||
-- =========================================================================
|
||||
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 INTEGER NOT NULL,
|
||||
r2_epoch_key TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Admin events
|
||||
-- =========================================================================
|
||||
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);
|
||||
|
||||
-- =========================================================================
|
||||
-- Exports
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS exports (
|
||||
export_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
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);
|
||||
|
||||
-- =========================================================================
|
||||
-- Seed schema version
|
||||
-- =========================================================================
|
||||
INSERT INTO schema_versions (version, applied_at, description)
|
||||
VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema (combined)')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
Generated
+2785
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@elydora/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:worker": "tsx watch src/worker.ts",
|
||||
"dev:scheduler": "tsx watch src/scheduler.ts",
|
||||
"start": "node dist/index.js",
|
||||
"start:worker": "node dist/worker.js",
|
||||
"start:scheduler": "node dist/scheduler.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.0.0",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"bullmq": "^5.0.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"hono": "^4.7.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"node-cron": "^3.0.0",
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node-cron": "^3.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* BullMQ queue adapter implementing the MessageQueue interface.
|
||||
*
|
||||
* Wraps a BullMQ Queue instance to replace Cloudflare's Queue producer.
|
||||
* Jobs are enqueued with retry-on-failure semantics (3 attempts,
|
||||
* exponential back-off) matching the Cloudflare Queues default behaviour.
|
||||
*/
|
||||
|
||||
import { Queue as BullQueue } from 'bullmq';
|
||||
import type { ConnectionOptions } from 'bullmq';
|
||||
import type { MessageQueue } from './interfaces.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
|
||||
export const QUEUE_NAME = 'elydora-queue';
|
||||
|
||||
export class BullMQAdapter implements MessageQueue {
|
||||
private readonly queue: BullQueue;
|
||||
|
||||
constructor(connection: ConnectionOptions) {
|
||||
this.queue = new BullQueue(QUEUE_NAME, { connection });
|
||||
}
|
||||
|
||||
async send(body: unknown): Promise<{ messageId: string }> {
|
||||
const job = await this.queue.add('message', body as Record<string, unknown>, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
removeOnComplete: { count: 100 },
|
||||
removeOnFail: { count: 500 },
|
||||
});
|
||||
return { messageId: job.id ?? generateUUIDv7() };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.queue.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Adapter interfaces for ElydoraOpenSource infrastructure services.
|
||||
*
|
||||
* These interfaces mirror the shape of Cloudflare's D1, R2, KV, and Queue
|
||||
* APIs so that all existing service code can run unchanged against
|
||||
* PostgreSQL, MinIO, Redis, and BullMQ implementations.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database (replaces D1Database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A prepared (and optionally bound) SQL statement.
|
||||
* Mirrors D1PreparedStatement's interface exactly.
|
||||
*/
|
||||
export interface PreparedStatement {
|
||||
/**
|
||||
* Bind positional parameters (? placeholders) to the statement.
|
||||
* Returns a new statement with the values set.
|
||||
*/
|
||||
bind(...values: unknown[]): PreparedStatement;
|
||||
|
||||
/** Execute and return the first row, or null if no rows match. */
|
||||
first<T = unknown>(): Promise<T | null>;
|
||||
|
||||
/** Execute and return all matching rows. */
|
||||
all<T = unknown>(): Promise<{ results: T[] }>;
|
||||
|
||||
/** Execute a write statement (INSERT/UPDATE/DELETE). */
|
||||
run(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relational database adapter.
|
||||
* Mirrors the subset of D1Database methods used by Elydora services.
|
||||
*/
|
||||
export interface Database {
|
||||
/** Prepare a SQL statement for execution. */
|
||||
prepare(sql: string): PreparedStatement;
|
||||
|
||||
/**
|
||||
* Execute multiple prepared statements atomically (within a transaction).
|
||||
* Mirrors D1Database.batch().
|
||||
*/
|
||||
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object store (replaces R2Bucket)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ObjectStorePutOptions {
|
||||
httpMetadata?: { contentType?: string };
|
||||
customMetadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ObjectStoreObject {
|
||||
/** Readable body stream of the stored object. */
|
||||
readonly body: ReadableStream;
|
||||
/** Object size in bytes, if known. */
|
||||
readonly size?: number;
|
||||
/** HTTP metadata associated with the object. */
|
||||
readonly httpMetadata?: { readonly contentType?: string };
|
||||
/** Parse the body as JSON. */
|
||||
json<T = unknown>(): Promise<T>;
|
||||
/** Return the body as a UTF-8 string. */
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ObjectStoreHead {
|
||||
readonly size?: number;
|
||||
readonly httpMetadata?: { readonly contentType?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Object/blob storage adapter.
|
||||
* Mirrors the subset of R2Bucket methods used by Elydora services.
|
||||
*/
|
||||
export interface ObjectStore {
|
||||
/** Upload an object to the store. */
|
||||
put(
|
||||
key: string,
|
||||
body: string | Uint8Array | ReadableStream,
|
||||
options?: ObjectStorePutOptions,
|
||||
): Promise<void>;
|
||||
|
||||
/** Retrieve an object, or null if not found. */
|
||||
get(key: string): Promise<ObjectStoreObject | null>;
|
||||
|
||||
/** Check if an object exists (metadata only). Returns null if not found. */
|
||||
head(key: string): Promise<ObjectStoreHead | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache (replaces KVNamespace)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Key/value cache adapter.
|
||||
* Mirrors the subset of KVNamespace methods used by Elydora services.
|
||||
*/
|
||||
export interface Cache {
|
||||
/** Retrieve a cached value, or null if not found / expired. */
|
||||
get(key: string): Promise<string | null>;
|
||||
|
||||
/** Store a value in the cache with an optional TTL in seconds. */
|
||||
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message queue (replaces Queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Message queue adapter.
|
||||
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
|
||||
*/
|
||||
export interface MessageQueue {
|
||||
/** Enqueue a message. Returns the assigned message ID. */
|
||||
send(body: unknown): Promise<{ messageId: string }>;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* MinIO / S3-compatible object storage adapter implementing the ObjectStore interface.
|
||||
*
|
||||
* Uses the AWS SDK v3 S3 client configured with a custom endpoint for MinIO.
|
||||
* Responses are buffered in memory so that both streaming (body) and random
|
||||
* access (json/text) are available on the returned ObjectStoreObject.
|
||||
*/
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
type PutObjectCommandInput,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import type {
|
||||
ObjectStore,
|
||||
ObjectStoreObject,
|
||||
ObjectStoreHead,
|
||||
ObjectStorePutOptions,
|
||||
} from './interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 error detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isNoSuchKey(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const code = (err as Record<string, unknown>).Code ?? (err as Record<string, unknown>).name;
|
||||
return code === 'NoSuchKey' || code === 'NotFound';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MinIO adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class MinioAdapter implements ObjectStore {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(endpoint: string, accessKey: string, secretKey: string, bucket: string) {
|
||||
this.client = new S3Client({
|
||||
endpoint,
|
||||
region: 'us-east-1', // required by S3 SDK, MinIO ignores it
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle: true, // required for MinIO
|
||||
});
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: string | Uint8Array | ReadableStream,
|
||||
options?: ObjectStorePutOptions,
|
||||
): Promise<void> {
|
||||
let bodyData: Buffer | string | Uint8Array;
|
||||
|
||||
if (typeof body === 'string') {
|
||||
bodyData = body;
|
||||
} else if (body instanceof Uint8Array) {
|
||||
bodyData = body;
|
||||
} else {
|
||||
// Web ReadableStream — collect into buffer
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
bodyData = Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const params: PutObjectCommandInput = {
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: bodyData,
|
||||
};
|
||||
|
||||
if (options?.httpMetadata?.contentType) {
|
||||
params.ContentType = options.httpMetadata.contentType;
|
||||
}
|
||||
|
||||
if (options?.customMetadata) {
|
||||
params.Metadata = options.customMetadata;
|
||||
}
|
||||
|
||||
await this.client.send(new PutObjectCommand(params));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<ObjectStoreObject | null> {
|
||||
try {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
);
|
||||
|
||||
if (!response.Body) return null;
|
||||
|
||||
// Buffer the entire response so that json(), text(), and body can all work
|
||||
const nodeReadable = response.Body as Readable;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of nodeReadable) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const contentType = response.ContentType;
|
||||
const contentLength = response.ContentLength;
|
||||
|
||||
return {
|
||||
get body(): ReadableStream {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(buffer);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
},
|
||||
size: contentLength,
|
||||
httpMetadata: contentType ? { contentType } : undefined,
|
||||
async json<T = unknown>(): Promise<T> {
|
||||
return JSON.parse(buffer.toString('utf-8')) as T;
|
||||
},
|
||||
async text(): Promise<string> {
|
||||
return buffer.toString('utf-8');
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNoSuchKey(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async head(key: string): Promise<ObjectStoreHead | null> {
|
||||
try {
|
||||
const response = await this.client.send(
|
||||
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
);
|
||||
return {
|
||||
size: response.ContentLength,
|
||||
httpMetadata: response.ContentType ? { contentType: response.ContentType } : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNoSuchKey(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* PostgreSQL adapter implementing the Database interface.
|
||||
*
|
||||
* Translates the D1-style API (prepare/bind/first/all/run/batch) into
|
||||
* standard `pg` Pool queries. SQLite `?` placeholders are converted to
|
||||
* PostgreSQL `$N` positional parameters automatically.
|
||||
*
|
||||
* Batch execution runs all statements inside a single transaction to
|
||||
* preserve the atomicity guarantee of D1.batch().
|
||||
*/
|
||||
|
||||
import pg from 'pg';
|
||||
import type { Database, PreparedStatement } from './interfaces.js';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
type Pool = InstanceType<typeof pg.Pool>;
|
||||
type PoolClient = pg.PoolClient;
|
||||
|
||||
// Parse PostgreSQL BIGINT (oid 20) as JavaScript number instead of string.
|
||||
// Elydora timestamps are Unix milliseconds (~1.7e12) which fit safely within
|
||||
// Number.MAX_SAFE_INTEGER (~9e15).
|
||||
types.setTypeParser(20, (val: string) => Number(val));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert SQLite `?` positional placeholders to PostgreSQL `$N` style.
|
||||
* Only replaces `?` outside of SQL string literals (safe for all Elydora queries).
|
||||
*/
|
||||
function convertPlaceholders(sql: string): string {
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statement implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Internal PostgreSQL statement that implements PreparedStatement.
|
||||
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
|
||||
*/
|
||||
class PostgresStatement implements PreparedStatement {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sql: string,
|
||||
private readonly values: unknown[],
|
||||
) {}
|
||||
|
||||
bind(...values: unknown[]): PreparedStatement {
|
||||
return new PostgresStatement(this.pool, this.sql, values);
|
||||
}
|
||||
|
||||
async first<T = unknown>(): Promise<T | null> {
|
||||
const result = await this.pool.query<Record<string, unknown>>({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return (result.rows[0] as T) ?? null;
|
||||
}
|
||||
|
||||
async all<T = unknown>(): Promise<{ results: T[] }> {
|
||||
const result = await this.pool.query<Record<string, unknown>>({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return { results: result.rows as T[] };
|
||||
}
|
||||
|
||||
async run(): Promise<{ success: boolean }> {
|
||||
await this.pool.query({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Execute using a specific pooled client (used by batch transaction). */
|
||||
async executeWith(client: PoolClient): Promise<{ results: unknown[]; success: boolean }> {
|
||||
const result = await client.query({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return { results: result.rows, success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostgreSQL adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PostgresAdapter implements Database {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(connectionString: string) {
|
||||
this.pool = new Pool({ connectionString });
|
||||
}
|
||||
|
||||
prepare(sql: string): PreparedStatement {
|
||||
return new PostgresStatement(this.pool, sql, []);
|
||||
}
|
||||
|
||||
async batch(
|
||||
statements: PreparedStatement[],
|
||||
): Promise<{ results: unknown[]; success: boolean }[]> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const results: { results: unknown[]; success: boolean }[] = [];
|
||||
for (const stmt of statements) {
|
||||
const pgStmt = stmt as PostgresStatement;
|
||||
results.push(await pgStmt.executeWith(client));
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return results;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the connection pool (call on graceful shutdown). */
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Redis cache adapter implementing the Cache interface.
|
||||
*
|
||||
* Uses ioredis to back the nonce-deduplication and chain-hash caching
|
||||
* that was previously served by Cloudflare KVNamespace.
|
||||
*/
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import type { Cache } from './interfaces.js';
|
||||
|
||||
export class RedisCacheAdapter implements Cache {
|
||||
private readonly client: Redis;
|
||||
|
||||
constructor(redisUrl: string) {
|
||||
this.client = new Redis(redisUrl, { lazyConnect: true });
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: { expirationTtl?: number },
|
||||
): Promise<void> {
|
||||
if (options?.expirationTtl) {
|
||||
await this.client.set(key, value, 'EX', options.expirationTtl);
|
||||
} else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Internationalization (i18n) translation map.
|
||||
*
|
||||
* Provides bilingual (English / Chinese) translations for all user-facing
|
||||
* error and validation messages returned by the Elydora API.
|
||||
*
|
||||
* Message keys follow a dot-separated convention:
|
||||
* <domain>.<specificMessage>
|
||||
*
|
||||
* Some messages accept parameters via simple {param} placeholders that
|
||||
* callers must replace before returning the message to the client.
|
||||
*/
|
||||
|
||||
export type Lang = 'en' | 'zh';
|
||||
|
||||
const messages: Record<string, Record<Lang, string>> = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error code default messages (src/shared/constants/errors.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'error.invalidSignature': {
|
||||
en: 'The operation signature is invalid.',
|
||||
zh: '\u64cd\u4f5c\u7b7e\u540d\u65e0\u6548\u3002',
|
||||
},
|
||||
'error.unknownAgent': {
|
||||
en: 'The specified agent does not exist.',
|
||||
zh: '\u6307\u5b9a\u7684\u4ee3\u7406\u4e0d\u5b58\u5728\u3002',
|
||||
},
|
||||
'error.keyRevoked': {
|
||||
en: 'The signing key has been revoked.',
|
||||
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
|
||||
},
|
||||
'error.agentFrozen': {
|
||||
en: 'The agent is frozen and cannot submit operations.',
|
||||
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002',
|
||||
},
|
||||
'error.ttlExpired': {
|
||||
en: 'The operation TTL has expired.',
|
||||
zh: '\u64cd\u4f5c\u7684 TTL \u5df2\u8fc7\u671f\u3002',
|
||||
},
|
||||
'error.replayDetected': {
|
||||
en: 'A duplicate nonce was detected (replay attack).',
|
||||
zh: '\u68c0\u6d4b\u5230\u91cd\u590d\u7684\u968f\u673a\u6570\uff08\u91cd\u653e\u653b\u51fb\uff09\u3002',
|
||||
},
|
||||
'error.prevHashMismatch': {
|
||||
en: 'The previous chain hash does not match.',
|
||||
zh: '\u524d\u4e00\u4e2a\u94fe\u54c8\u5e0c\u4e0d\u5339\u914d\u3002',
|
||||
},
|
||||
'error.payloadTooLarge': {
|
||||
en: 'The operation payload exceeds the maximum allowed size.',
|
||||
zh: '\u64cd\u4f5c\u8d1f\u8f7d\u8d85\u8fc7\u4e86\u5141\u8bb8\u7684\u6700\u5927\u5927\u5c0f\u3002',
|
||||
},
|
||||
'error.rateLimited': {
|
||||
en: 'Too many requests. Please retry later.',
|
||||
zh: '\u8bf7\u6c42\u8fc7\u591a\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
},
|
||||
'error.internalError': {
|
||||
en: 'An internal server error occurred.',
|
||||
zh: '\u53d1\u751f\u4e86\u5185\u90e8\u670d\u52a1\u5668\u9519\u8bef\u3002',
|
||||
},
|
||||
'error.unauthorized': {
|
||||
en: 'Authentication is required.',
|
||||
zh: '\u9700\u8981\u8eab\u4efd\u9a8c\u8bc1\u3002',
|
||||
},
|
||||
'error.forbidden': {
|
||||
en: 'You do not have permission to perform this action.',
|
||||
zh: '\u60a8\u6ca1\u6709\u6267\u884c\u6b64\u64cd\u4f5c\u7684\u6743\u9650\u3002',
|
||||
},
|
||||
'error.notFound': {
|
||||
en: 'The requested resource was not found.',
|
||||
zh: '\u672a\u627e\u5230\u8bf7\u6c42\u7684\u8d44\u6e90\u3002',
|
||||
},
|
||||
'error.validationError': {
|
||||
en: 'The request failed validation.',
|
||||
zh: '\u8bf7\u6c42\u672a\u901a\u8fc7\u9a8c\u8bc1\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth middleware (src/middleware/auth.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'auth.missingHeader': {
|
||||
en: 'Missing Authorization header',
|
||||
zh: '\u7f3a\u5c11 Authorization \u8bf7\u6c42\u5934',
|
||||
},
|
||||
'auth.bearerRequired': {
|
||||
en: 'Authorization header must use Bearer scheme',
|
||||
zh: 'Authorization \u8bf7\u6c42\u5934\u5fc5\u987b\u4f7f\u7528 Bearer \u65b9\u6848',
|
||||
},
|
||||
'auth.emptyToken': {
|
||||
en: 'Empty Bearer token',
|
||||
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
||||
},
|
||||
'auth.malformedJwt': {
|
||||
en: 'Malformed JWT: expected 3 parts',
|
||||
zh: 'JWT \u683c\u5f0f\u9519\u8bef\uff1a\u5e94\u5305\u542b 3 \u4e2a\u90e8\u5206',
|
||||
},
|
||||
'auth.invalidSignature': {
|
||||
en: 'Invalid JWT signature',
|
||||
zh: 'JWT \u7b7e\u540d\u65e0\u6548',
|
||||
},
|
||||
'auth.malformedPayload': {
|
||||
en: 'Malformed JWT payload',
|
||||
zh: 'JWT \u8d1f\u8f7d\u683c\u5f0f\u9519\u8bef',
|
||||
},
|
||||
'auth.missingSub': {
|
||||
en: 'JWT missing required "sub" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "sub" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingOrgId': {
|
||||
en: 'JWT missing required "org_id" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "org_id" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingRole': {
|
||||
en: 'JWT missing required "role" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "role" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingExp': {
|
||||
en: 'JWT missing required "exp" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "exp" \u58f0\u660e',
|
||||
},
|
||||
'auth.expired': {
|
||||
en: 'JWT has expired',
|
||||
zh: 'JWT \u5df2\u8fc7\u671f',
|
||||
},
|
||||
'auth.unsupportedAlgorithm': {
|
||||
en: 'Unsupported JWT algorithm. Only HS256 is accepted.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684 JWT \u7b97\u6cd5\u3002\u4ec5\u63a5\u53d7 HS256\u3002',
|
||||
},
|
||||
'auth.ttlMustBePositive': {
|
||||
en: 'ttl_seconds must be a positive number',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RBAC middleware (src/middleware/rbac.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'rbac.noRole': {
|
||||
en: 'No role found in request context',
|
||||
zh: '\u8bf7\u6c42\u4e0a\u4e0b\u6587\u4e2d\u672a\u627e\u5230\u89d2\u8272',
|
||||
},
|
||||
'rbac.insufficientPermissions': {
|
||||
en: 'Insufficient permissions for this action.',
|
||||
zh: '\u6743\u9650\u4e0d\u8db3\uff0c\u65e0\u6cd5\u6267\u884c\u6b64\u64cd\u4f5c\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 404 handler (src/index.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'notFound.resource': {
|
||||
en: 'The requested resource was not found.',
|
||||
zh: '\u672a\u627e\u5230\u8bf7\u6c42\u7684\u8d44\u6e90\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth routes (src/routes/auth.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'auth.ttlMustBeNumber': {
|
||||
en: 'ttl_seconds must be a number or null',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent routes (src/routes/agents.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'agent.missingAgentId': {
|
||||
en: 'Missing required field "agent_id".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "agent_id"\u3002',
|
||||
},
|
||||
'agent.missingIntegrationType': {
|
||||
en: 'Missing required field "integration_type".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "integration_type"\u3002',
|
||||
},
|
||||
'agent.missingReason': {
|
||||
en: 'Missing required field "reason".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "reason"\u3002',
|
||||
},
|
||||
'agent.missingKid': {
|
||||
en: 'Missing required field "kid".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "kid"\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation routes (src/routes/operations.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'operation.invalidBody': {
|
||||
en: 'Invalid or empty request body.',
|
||||
zh: '\u8bf7\u6c42\u4f53\u65e0\u6548\u6216\u4e3a\u7a7a\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export routes (src/routes/exports.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'export.bodyRequired': {
|
||||
en: 'Request body is required.',
|
||||
zh: '\u8bf7\u6c42\u4f53\u4e3a\u5fc5\u586b\u9879\u3002',
|
||||
},
|
||||
'export.notFoundById': {
|
||||
en: 'Export "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u5bfc\u51fa\u8bb0\u5f55 "{id}"\u3002',
|
||||
},
|
||||
'export.notYetComplete': {
|
||||
en: 'Export is not yet complete.',
|
||||
zh: '\u5bfc\u51fa\u5c1a\u672a\u5b8c\u6210\u3002',
|
||||
},
|
||||
'export.fileNotFound': {
|
||||
en: 'Export file not found in storage.',
|
||||
zh: '\u5728\u5b58\u50a8\u4e2d\u672a\u627e\u5230\u5bfc\u51fa\u6587\u4ef6\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth service (src/services/auth-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'auth.emailPasswordRequired': {
|
||||
en: 'Email and password (8-128 chars) are required',
|
||||
zh: '\u7535\u5b50\u90ae\u4ef6\u548c\u5bc6\u7801\uff088-128 \u4e2a\u5b57\u7b26\uff09\u4e3a\u5fc5\u586b\u9879',
|
||||
},
|
||||
'auth.emailAlreadyRegistered': {
|
||||
en: 'Email already registered',
|
||||
zh: '\u7535\u5b50\u90ae\u4ef6\u5df2\u88ab\u6ce8\u518c',
|
||||
},
|
||||
'auth.invalidCredentials': {
|
||||
en: 'Invalid email or password',
|
||||
zh: '\u7535\u5b50\u90ae\u4ef6\u6216\u5bc6\u7801\u65e0\u6548',
|
||||
},
|
||||
'auth.accountSuspended': {
|
||||
en: 'Account is suspended',
|
||||
zh: '\u8d26\u6237\u5df2\u88ab\u505c\u7528',
|
||||
},
|
||||
'auth.userNotFound': {
|
||||
en: 'User not found',
|
||||
zh: '\u672a\u627e\u5230\u7528\u6237',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent service (src/services/agent-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'agent.alreadyExists': {
|
||||
en: 'An agent with id "{id}" already exists.',
|
||||
zh: 'ID \u4e3a "{id}" \u7684\u4ee3\u7406\u5df2\u5b58\u5728\u3002',
|
||||
},
|
||||
'agent.atLeastOneKey': {
|
||||
en: 'At least one signing key must be provided.',
|
||||
zh: '\u5fc5\u987b\u63d0\u4f9b\u81f3\u5c11\u4e00\u4e2a\u7b7e\u540d\u5bc6\u94a5\u3002',
|
||||
},
|
||||
'agent.notFound': {
|
||||
en: 'Agent "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}"\u3002',
|
||||
},
|
||||
'agent.invalidIntegrationType': {
|
||||
en: 'Invalid integration_type "{value}". Must be one of: {valid}.',
|
||||
zh: '\u65e0\u6548\u7684 integration_type "{value}"\u3002\u5fc5\u987b\u4e3a\u4ee5\u4e0b\u4e4b\u4e00\uff1a{valid}\u3002',
|
||||
},
|
||||
'agent.alreadyFrozen': {
|
||||
en: 'Agent "{id}" is already frozen.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u51bb\u7ed3\u3002',
|
||||
},
|
||||
'agent.permanentlyRevoked': {
|
||||
en: 'Agent "{id}" has been permanently revoked.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u6c38\u4e45\u64a4\u9500\u3002',
|
||||
},
|
||||
'agent.notFrozen': {
|
||||
en: 'Agent "{id}" is not frozen.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u672a\u88ab\u51bb\u7ed3\u3002',
|
||||
},
|
||||
'agent.permanentlyRevokedCannotUnfreeze': {
|
||||
en: 'Agent "{id}" has been permanently revoked and cannot be unfrozen.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u6c38\u4e45\u64a4\u9500\uff0c\u65e0\u6cd5\u89e3\u51bb\u3002',
|
||||
},
|
||||
'agent.keyNotFound': {
|
||||
en: 'Key "{kid}" not found for agent "{id}".',
|
||||
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}" \u7684\u5bc6\u94a5 "{kid}"\u3002',
|
||||
},
|
||||
'agent.keyAlreadyRevoked': {
|
||||
en: 'Key "{kid}" is already revoked.',
|
||||
zh: '\u5bc6\u94a5 "{kid}" \u5df2\u88ab\u64a4\u9500\u3002',
|
||||
},
|
||||
'agent.unsupportedAlgorithm': {
|
||||
en: 'Unsupported key algorithm "{algorithm}". Only "ed25519" is supported.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684\u5bc6\u94a5\u7b97\u6cd5 "{algorithm}"\u3002\u4ec5\u652f\u6301 "ed25519"\u3002',
|
||||
},
|
||||
'agent.invalidPublicKeyLength': {
|
||||
en: 'Public key "{kid}" must be 32 bytes (got {actual}).',
|
||||
zh: '\u516c\u94a5 "{kid}" \u5fc5\u987b\u4e3a 32 \u5b57\u8282\uff08\u5b9e\u9645 {actual}\uff09\u3002',
|
||||
},
|
||||
'agent.invalidPublicKeyEncoding': {
|
||||
en: 'Public key "{kid}" has invalid base64url encoding.',
|
||||
zh: '\u516c\u94a5 "{kid}" \u7684 base64url \u7f16\u7801\u65e0\u6548\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation service (src/services/operation-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'operation.agentNotRegistered': {
|
||||
en: 'Agent "{id}" is not registered.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u672a\u6ce8\u518c\u3002',
|
||||
},
|
||||
'operation.agentRevoked': {
|
||||
en: 'The agent has been permanently revoked.',
|
||||
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u6c38\u4e45\u64a4\u9500\u3002',
|
||||
},
|
||||
'operation.keyNotFoundForAgent': {
|
||||
en: 'Key "{kid}" not found for agent "{id}".',
|
||||
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}" \u7684\u5bc6\u94a5 "{kid}"\u3002',
|
||||
},
|
||||
'operation.keyRetired': {
|
||||
en: 'The signing key has been retired.',
|
||||
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u505c\u7528\u3002',
|
||||
},
|
||||
'operation.prevHashMismatch': {
|
||||
en: 'Expected prev_chain_hash "{expected}", got "{actual}".',
|
||||
zh: '\u9884\u671f\u7684 prev_chain_hash \u4e3a "{expected}"\uff0c\u5b9e\u9645\u4e3a "{actual}"\u3002',
|
||||
},
|
||||
'operation.unsupportedVersion': {
|
||||
en: 'Unsupported op_version "{version}". Only "1.0" is supported.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684 op_version "{version}"\u3002\u4ec5\u652f\u6301 "1.0"\u3002',
|
||||
},
|
||||
'operation.missingField': {
|
||||
en: 'Missing or empty required field "{field}".',
|
||||
zh: '\u7f3a\u5c11\u6216\u4e3a\u7a7a\u7684\u5fc5\u586b\u5b57\u6bb5 "{field}"\u3002',
|
||||
},
|
||||
'operation.nonceTooLong': {
|
||||
en: 'Nonce exceeds maximum length of {max} characters.',
|
||||
zh: '\u968f\u673a\u6570\u8d85\u8fc7\u4e86\u6700\u5927\u957f\u5ea6 {max} \u4e2a\u5b57\u7b26\u3002',
|
||||
},
|
||||
'operation.invalidIssuedAt': {
|
||||
en: 'Invalid "issued_at" timestamp.',
|
||||
zh: '\u65e0\u6548\u7684 "issued_at" \u65f6\u95f4\u6233\u3002',
|
||||
},
|
||||
'operation.missingTtl': {
|
||||
en: 'Missing or invalid "ttl_ms".',
|
||||
zh: '\u7f3a\u5c11\u6216\u65e0\u6548\u7684 "ttl_ms"\u3002',
|
||||
},
|
||||
'operation.ttlTooLow': {
|
||||
en: 'ttl_ms must be at least {min}ms.',
|
||||
zh: 'ttl_ms \u81f3\u5c11\u4e3a {min} \u6beb\u79d2\u3002',
|
||||
},
|
||||
'operation.ttlTooHigh': {
|
||||
en: 'ttl_ms must not exceed {max}ms.',
|
||||
zh: 'ttl_ms \u4e0d\u80fd\u8d85\u8fc7 {max} \u6beb\u79d2\u3002',
|
||||
},
|
||||
'operation.notFound': {
|
||||
en: 'Operation "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u64cd\u4f5c "{id}"\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export service (src/services/export-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'export.invalidStartTime': {
|
||||
en: 'Invalid "start_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "start_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'export.invalidEndTime': {
|
||||
en: 'Invalid "end_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "end_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'export.startBeforeEnd': {
|
||||
en: '"start_time" must be before "end_time".',
|
||||
zh: '"start_time" \u5fc5\u987b\u65e9\u4e8e "end_time"\u3002',
|
||||
},
|
||||
'export.invalidFormat': {
|
||||
en: 'Format must be "json" or "pdf".',
|
||||
zh: '\u683c\u5f0f\u5fc5\u987b\u4e3a "json" \u6216 "pdf"\u3002',
|
||||
},
|
||||
'export.notFound': {
|
||||
en: 'Export "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u5bfc\u51fa\u8bb0\u5f55 "{id}"\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit service (src/services/audit-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'audit.invalidStartTime': {
|
||||
en: 'Invalid "start_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "start_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'audit.invalidEndTime': {
|
||||
en: 'Invalid "end_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "end_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'audit.invalidCursor': {
|
||||
en: 'Invalid cursor value.',
|
||||
zh: '\u65e0\u6548\u7684\u6e38\u6807\u503c\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Epoch service (src/services/epoch-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'epoch.notFound': {
|
||||
en: 'Epoch "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u65f6\u671f "{id}"\u3002',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a translated message by key.
|
||||
*
|
||||
* Supports simple {param} placeholder interpolation via the optional
|
||||
* `params` argument.
|
||||
*
|
||||
* Falls back to English if the requested language is missing, and
|
||||
* ultimately falls back to the raw key if no translation exists at all.
|
||||
*/
|
||||
export function getMessage(
|
||||
key: string,
|
||||
lang: Lang = 'en',
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
let msg = messages[key]?.[lang] ?? messages[key]?.en ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
msg = msg.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the preferred language from an Accept-Language header value.
|
||||
*
|
||||
* Returns 'zh' if the header contains any Chinese locale indicator,
|
||||
* otherwise defaults to 'en'.
|
||||
*/
|
||||
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
|
||||
if (!acceptLanguage) return 'en';
|
||||
if (acceptLanguage.includes('zh')) return 'zh';
|
||||
return 'en';
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Elydora API — Node.js entry point.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Env, AppVariables } from './types.js';
|
||||
import { requestIdMiddleware } from './middleware/request-id.js';
|
||||
import { i18nMiddleware } from './middleware/i18n.js';
|
||||
import { globalErrorHandler } from './middleware/error-handler.js';
|
||||
import { auth } from './routes/auth.js';
|
||||
import { agents } from './routes/agents.js';
|
||||
import { operations } from './routes/operations.js';
|
||||
import { audit } from './routes/audit.js';
|
||||
import { epochs } from './routes/epochs.js';
|
||||
import { exports_ } from './routes/exports.js';
|
||||
import { jwks } from './routes/jwks.js';
|
||||
import { getMessage } from './i18n/messages.js';
|
||||
|
||||
// Adapter imports
|
||||
import { PostgresAdapter } from './adapters/postgres.js';
|
||||
import { MinioAdapter } from './adapters/minio.js';
|
||||
import { RedisCacheAdapter } from './adapters/redis-cache.js';
|
||||
import { BullMQAdapter } from './adapters/bullmq.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize adapters from environment variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = new PostgresAdapter(process.env.DATABASE_URL!);
|
||||
const evidence = new MinioAdapter(
|
||||
process.env.MINIO_ENDPOINT!,
|
||||
process.env.MINIO_ACCESS_KEY!,
|
||||
process.env.MINIO_SECRET_KEY!,
|
||||
process.env.MINIO_BUCKET ?? 'elydora-evidence',
|
||||
);
|
||||
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
|
||||
const redisUrl = new URL(process.env.REDIS_URL!);
|
||||
const queue = new BullMQAdapter({
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port) || 6379,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the env bindings object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const env: Env = {
|
||||
ELYDORA_DB: db,
|
||||
ELYDORA_EVIDENCE: evidence,
|
||||
ELYDORA_CACHE: cache,
|
||||
ELYDORA_QUEUE: queue,
|
||||
ENVIRONMENT: process.env.ENVIRONMENT ?? 'production',
|
||||
API_VERSION: process.env.API_VERSION ?? 'v1',
|
||||
PROTOCOL_VERSION: process.env.PROTOCOL_VERSION ?? '1.0',
|
||||
JWT_SECRET: process.env.JWT_SECRET!,
|
||||
ELYDORA_SIGNING_KEY: process.env.ELYDORA_SIGNING_KEY!,
|
||||
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? '',
|
||||
TSA_URL: process.env.TSA_URL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create the Hono application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// Inject env bindings into every request context
|
||||
app.use('/*', async (c, next) => {
|
||||
// Copy all env bindings onto c.env
|
||||
Object.assign(c.env, env);
|
||||
await next();
|
||||
});
|
||||
|
||||
// CORS
|
||||
app.use(
|
||||
'/*',
|
||||
cors({
|
||||
origin: (origin, c) => {
|
||||
if (c.env.ENVIRONMENT === 'production') {
|
||||
const raw = c.env.ALLOWED_ORIGINS ?? '';
|
||||
const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
return allowedOrigins.includes(origin) ? origin : '';
|
||||
}
|
||||
return origin;
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'X-Elydora-Protocol-Version',
|
||||
'X-Elydora-Signature',
|
||||
'X-Request-Id',
|
||||
],
|
||||
exposeHeaders: [
|
||||
'X-Request-Id',
|
||||
'X-Elydora-Protocol-Version',
|
||||
'X-RateLimit-Limit',
|
||||
'X-RateLimit-Remaining',
|
||||
'X-RateLimit-Reset',
|
||||
],
|
||||
maxAge: 86400,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Security headers
|
||||
app.use('/*', async (c, next) => {
|
||||
await next();
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
||||
if (c.env.ENVIRONMENT === 'production') {
|
||||
c.header('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
|
||||
}
|
||||
});
|
||||
|
||||
// Request ID
|
||||
app.use('/*', requestIdMiddleware);
|
||||
|
||||
// Language detection
|
||||
app.use('/*', i18nMiddleware);
|
||||
|
||||
// Global error handler
|
||||
app.onError(globalErrorHandler);
|
||||
|
||||
// Health check
|
||||
app.get('/v1/health', (c) => {
|
||||
return c.json(
|
||||
{
|
||||
status: 'healthy',
|
||||
version: c.env.API_VERSION,
|
||||
protocol_version: c.env.PROTOCOL_VERSION,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
// Route groups
|
||||
app.route('/.well-known/elydora/jwks.json', jwks);
|
||||
app.route('/v1/auth', auth);
|
||||
app.route('/v1/agents', agents);
|
||||
app.route('/v1/operations', operations);
|
||||
app.route('/v1/audit', audit);
|
||||
app.route('/v1/epochs', epochs);
|
||||
app.route('/v1/exports', exports_);
|
||||
|
||||
// 404 fallback
|
||||
app.notFound((c) => {
|
||||
const requestId = c.get('request_id') ?? 'unknown';
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: getMessage('notFound.resource', lang),
|
||||
request_id: requestId,
|
||||
},
|
||||
},
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start the server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const port = Number(process.env.PORT) || 8787;
|
||||
|
||||
await cache.connect();
|
||||
console.log(`Elydora API server starting on port ${port}`);
|
||||
|
||||
serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`Elydora API server listening on http://localhost:${info.port}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down...');
|
||||
await cache.close();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('SIGINT received, shutting down...');
|
||||
await cache.close();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* JWT authentication middleware.
|
||||
*
|
||||
* Verifies Bearer tokens in the Authorization header using HMAC-SHA256.
|
||||
* On success, extracts `org_id`, `role`, and `sub` (actor) claims and
|
||||
* stores them in the Hono context variables for downstream handlers.
|
||||
*
|
||||
* Token structure (payload):
|
||||
* {
|
||||
* "sub": "user_abc123", // actor identifier
|
||||
* "org_id": "org_acme", // organization scope
|
||||
* "role": "org_owner", // RBAC role
|
||||
* "iat": 1700000000, // issued-at (seconds)
|
||||
* "exp": 1700003600 // expiration (seconds)
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
import { base64urlDecode } from '../utils/crypto.js';
|
||||
|
||||
/** Minimal JWT payload shape we require. */
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
org_id: string;
|
||||
role: RbacRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT using HMAC-SHA256 and return its payload.
|
||||
*/
|
||||
async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Validate JWT header — only HS256 is accepted
|
||||
try {
|
||||
const headerBytes = base64urlDecode(headerB64);
|
||||
const header = JSON.parse(new TextDecoder().decode(headerBytes)) as { alg?: string; typ?: string };
|
||||
if (header.alg !== 'HS256') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.unsupportedAlgorithm' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AppError) throw e;
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
|
||||
}
|
||||
|
||||
// Import the HMAC key
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify'],
|
||||
);
|
||||
|
||||
// Verify signature over "header.payload"
|
||||
const signedInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signatureBytes = base64urlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as unknown as Uint8Array<ArrayBuffer>, signedInput as unknown as Uint8Array<ArrayBuffer>);
|
||||
|
||||
if (!valid) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSignature' });
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
const payloadBytes = base64urlDecode(payloadB64);
|
||||
const payloadJson = new TextDecoder().decode(payloadBytes);
|
||||
let payload: JWTPayload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadJson);
|
||||
} catch {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedPayload' });
|
||||
}
|
||||
|
||||
// Validate required claims
|
||||
if (!payload.sub || typeof payload.sub !== 'string') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingSub' });
|
||||
}
|
||||
if (!payload.org_id || typeof payload.org_id !== 'string') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingOrgId' });
|
||||
}
|
||||
if (!payload.role || typeof payload.role !== 'string') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingRole' });
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (typeof payload.exp !== 'number') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingExp' });
|
||||
}
|
||||
if (payload.exp < nowSeconds) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.expired' });
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware.
|
||||
*
|
||||
* Extracts the Bearer token, verifies it, and sets org_id, role, and
|
||||
* actor on the context. Should be applied to all routes that require
|
||||
* authentication.
|
||||
*/
|
||||
export const authMiddleware: MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
|
||||
}
|
||||
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.bearerRequired' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7).trim();
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.emptyToken' });
|
||||
}
|
||||
|
||||
const payload = await verifyJWT(token, c.env.JWT_SECRET);
|
||||
|
||||
c.set('org_id', payload.org_id);
|
||||
c.set('role', payload.role);
|
||||
c.set('actor', payload.sub);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
*
|
||||
* Catches all uncaught exceptions and formats them as a standardized
|
||||
* ErrorResponse with the appropriate HTTP status code, error code,
|
||||
* and the request's unique identifier for tracing.
|
||||
*
|
||||
* When an AppError carries a `messageKey`, the handler resolves it to
|
||||
* the correct language using the `lang` context variable set by the
|
||||
* i18n middleware. This allows services to throw errors with i18n keys
|
||||
* without needing direct access to the Hono context.
|
||||
*/
|
||||
|
||||
import type { ErrorHandler } from 'hono';
|
||||
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
|
||||
import { ERROR_CODES } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { getMessage } from '../i18n/messages.js';
|
||||
import type { Lang } from '../i18n/messages.js';
|
||||
|
||||
/**
|
||||
* Maps each ErrorCode to its corresponding i18n message key.
|
||||
*
|
||||
* This is used when an AppError is thrown with only an error code
|
||||
* (no explicit message or i18n key), so the global error handler
|
||||
* can still resolve a translated default message.
|
||||
*/
|
||||
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||
INVALID_SIGNATURE: 'error.invalidSignature',
|
||||
UNKNOWN_AGENT: 'error.unknownAgent',
|
||||
KEY_REVOKED: 'error.keyRevoked',
|
||||
AGENT_FROZEN: 'error.agentFrozen',
|
||||
TTL_EXPIRED: 'error.ttlExpired',
|
||||
REPLAY_DETECTED: 'error.replayDetected',
|
||||
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
|
||||
PAYLOAD_TOO_LARGE: 'error.payloadTooLarge',
|
||||
RATE_LIMITED: 'error.rateLimited',
|
||||
INTERNAL_ERROR: 'error.internalError',
|
||||
UNAUTHORIZED: 'error.unauthorized',
|
||||
FORBIDDEN: 'error.forbidden',
|
||||
NOT_FOUND: 'error.notFound',
|
||||
VALIDATION_ERROR: 'error.validationError',
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class that carries an HTTP status and Elydora error code.
|
||||
*
|
||||
* Supports three modes:
|
||||
* 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message
|
||||
* 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` —
|
||||
* the message is resolved at response time from the translation map
|
||||
* 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is
|
||||
* (should be avoided in new code; prefer mode 2)
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly errorCode: ErrorCode;
|
||||
public readonly details?: Record<string, unknown>;
|
||||
public readonly messageKey?: string;
|
||||
public readonly messageParams?: Record<string, string | number>;
|
||||
/** True when the error was created with only an error code (no custom message). */
|
||||
public readonly usesDefaultMessage: boolean;
|
||||
|
||||
constructor(
|
||||
statusCode: number,
|
||||
errorCode: ErrorCode,
|
||||
messageOrOpts?: string | { key: string; params?: Record<string, string | number> },
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
|
||||
// i18n mode — store the key; resolve later in the error handler
|
||||
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
|
||||
this.messageKey = messageOrOpts.key;
|
||||
this.messageParams = messageOrOpts.params;
|
||||
this.usesDefaultMessage = false;
|
||||
} else if (messageOrOpts === undefined) {
|
||||
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY
|
||||
super(ERROR_CODES[errorCode]);
|
||||
this.usesDefaultMessage = true;
|
||||
} else {
|
||||
// Raw string mode (legacy)
|
||||
super(messageOrOpts);
|
||||
this.usesDefaultMessage = false;
|
||||
}
|
||||
this.name = 'AppError';
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user-facing message for a given language.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If a messageKey was provided, use that with the translation map.
|
||||
* 2. If the error used the default ErrorCode message, translate via
|
||||
* the ERROR_CODE_TO_I18N_KEY mapping.
|
||||
* 3. Otherwise return the raw message string.
|
||||
*/
|
||||
resolveMessage(lang: Lang): string {
|
||||
if (this.messageKey) {
|
||||
return getMessage(this.messageKey, lang, this.messageParams);
|
||||
}
|
||||
if (this.usesDefaultMessage) {
|
||||
const i18nKey = ERROR_CODE_TO_I18N_KEY[this.errorCode];
|
||||
if (i18nKey) {
|
||||
return getMessage(i18nKey, lang);
|
||||
}
|
||||
}
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standardised error response body.
|
||||
*
|
||||
* When `lang` is provided and no explicit `message` is given, the
|
||||
* ErrorCode default message is resolved from the translation map.
|
||||
*/
|
||||
export function buildErrorResponse(
|
||||
code: ErrorCode,
|
||||
requestId: string,
|
||||
message?: string,
|
||||
details?: Record<string, unknown>,
|
||||
lang?: Lang,
|
||||
): ErrorResponse {
|
||||
let resolvedMessage = message;
|
||||
if (!resolvedMessage) {
|
||||
const i18nKey = ERROR_CODE_TO_I18N_KEY[code];
|
||||
resolvedMessage = i18nKey ? getMessage(i18nKey, lang ?? 'en') : ERROR_CODES[code];
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message: resolvedMessage,
|
||||
request_id: requestId,
|
||||
...(details ? { details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono error handler that converts all errors into the ErrorResponse format.
|
||||
*
|
||||
* Resolves i18n message keys using the `lang` context variable set by
|
||||
* the i18n middleware.
|
||||
*/
|
||||
export const globalErrorHandler: ErrorHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = (err, c) => {
|
||||
const requestId = c.get('request_id') ?? 'unknown';
|
||||
const lang: Lang = c.get('lang') ?? 'en';
|
||||
|
||||
if (err instanceof AppError) {
|
||||
const resolvedMessage = err.resolveMessage(lang);
|
||||
const body = buildErrorResponse(err.errorCode, requestId, resolvedMessage, err.details);
|
||||
return c.json(body, err.statusCode as 400);
|
||||
}
|
||||
|
||||
// Log unexpected errors for observability
|
||||
console.error(`[${requestId}] Unhandled error:`, err);
|
||||
|
||||
const body = buildErrorResponse('INTERNAL_ERROR', requestId, undefined, undefined, lang);
|
||||
return c.json(body, 500);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* i18n middleware — language detection from Accept-Language header.
|
||||
*
|
||||
* Reads the Accept-Language request header, detects the preferred
|
||||
* language (English or Chinese), and stores it in the Hono context
|
||||
* so that all downstream handlers and services can access it via
|
||||
* `c.get('lang')`.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { detectLanguage } from '../i18n/messages.js';
|
||||
|
||||
export const i18nMiddleware: MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = async (c, next) => {
|
||||
const acceptLanguage = c.req.header('Accept-Language');
|
||||
const lang = detectLanguage(acceptLanguage);
|
||||
c.set('lang', lang);
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Role-based access control (RBAC) middleware.
|
||||
*
|
||||
* The Elydora console defines five roles with a clear privilege hierarchy:
|
||||
*
|
||||
* org_owner > security_admin > compliance_auditor > readonly_investigator
|
||||
* integration_engineer (parallel to compliance_auditor, agent-management focused)
|
||||
*
|
||||
* This middleware checks whether the role extracted by the auth middleware
|
||||
* satisfies the minimum required role for a given route.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
|
||||
/**
|
||||
* Privilege level map. Higher number = more privilege.
|
||||
* integration_engineer is a special role with limited admin capabilities
|
||||
* for agent management only.
|
||||
*/
|
||||
const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||
readonly_investigator: 10,
|
||||
integration_engineer: 20,
|
||||
compliance_auditor: 30,
|
||||
security_admin: 40,
|
||||
org_owner: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a role has at least the given privilege level.
|
||||
*/
|
||||
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
|
||||
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an RBAC middleware that requires a minimum role.
|
||||
*
|
||||
* Usage:
|
||||
* app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler);
|
||||
*
|
||||
* @param minimumRole - The minimum RBAC role required to access the endpoint
|
||||
*/
|
||||
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> {
|
||||
return async (c, next) => {
|
||||
const userRole = c.get('role');
|
||||
|
||||
if (!userRole) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'rbac.noRole' });
|
||||
}
|
||||
|
||||
if (!hasMinimumRole(userRole, minimumRole)) {
|
||||
throw new AppError(
|
||||
403,
|
||||
'FORBIDDEN',
|
||||
{ key: 'rbac.insufficientPermissions' },
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Request ID middleware.
|
||||
*
|
||||
* Generates a UUIDv7 for every incoming request and stores it in the
|
||||
* Hono context variables. The ID is also set as the X-Request-Id
|
||||
* response header so clients can reference it when reporting issues.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
|
||||
export const requestIdMiddleware: MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = async (c, next) => {
|
||||
const requestId = generateUUIDv7();
|
||||
c.set('request_id', requestId);
|
||||
c.header('X-Request-Id', requestId);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Agent routes — registration, lookup, freezing, and key revocation.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { RegisterAgentRequest, UpdateAgentRequest, FreezeAgentRequest, UnfreezeAgentRequest, RevokeAgentRequest } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import * as agentService from '../services/agent-service.js';
|
||||
|
||||
const agents = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All agent routes require authentication
|
||||
agents.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/register
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/register',
|
||||
requireRole('integration_engineer'),
|
||||
async (c) => {
|
||||
const body = await c.req.json<RegisterAgentRequest>();
|
||||
|
||||
if (!body || !body.agent_id) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingAgentId' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.registerAgent(c.env.ELYDORA_DB, body, orgId, actor);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 201);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/agents
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.get(
|
||||
'/',
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const orgId = c.get('org_id');
|
||||
const result = await agentService.listAgents(c.env.ELYDORA_DB, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/agents/:agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.get(
|
||||
'/:agent_id',
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await agentService.getAgent(c.env.ELYDORA_DB, agentId, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /v1/agents/:agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.delete(
|
||||
'/:agent_id',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
await agentService.deleteAgent(c.env.ELYDORA_DB, agentId, orgId, actor);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json({ deleted: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /v1/agents/:agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.patch(
|
||||
'/:agent_id',
|
||||
requireRole('integration_engineer'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<UpdateAgentRequest>();
|
||||
|
||||
if (!body || !body.integration_type || typeof body.integration_type !== 'string') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingIntegrationType' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.updateAgentIntegrationType(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.integration_type,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/:agent_id/freeze
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/:agent_id/freeze',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<FreezeAgentRequest>();
|
||||
|
||||
if (!body || !body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.freezeAgent(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.reason,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/:agent_id/unfreeze
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/:agent_id/unfreeze',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<UnfreezeAgentRequest>();
|
||||
|
||||
if (!body || !body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.unfreezeAgent(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.reason,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/:agent_id/revoke
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/:agent_id/revoke',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<RevokeAgentRequest>();
|
||||
|
||||
if (!body || !body.kid || typeof body.kid !== 'string') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingKid' });
|
||||
}
|
||||
|
||||
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.revokeKey(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.kid,
|
||||
body.reason,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { agents };
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Audit routes — query the tamper-evident audit log.
|
||||
*
|
||||
* Per the OpenAPI spec, audit queries use POST /v1/audit/query with
|
||||
* filter parameters in the request body. This avoids URL length limits
|
||||
* and supports complex filter expressions.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { AuditQueryRequest } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import * as auditService from '../services/audit-service.js';
|
||||
|
||||
const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All audit routes require authentication and at least compliance_auditor role
|
||||
audit.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/audit/query — Query the audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
audit.post(
|
||||
'/query',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const body = await c.req.json<AuditQueryRequest>();
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await auditService.queryAudit(c.env.ELYDORA_DB, body ?? {}, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { audit };
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Auth routes — registration, login, profile retrieval, and token refresh.
|
||||
*
|
||||
* /register and /login are public (no auth required).
|
||||
* /me and /refresh require a valid JWT.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { registerUser, loginUser, getMe, issueJWT, issueApiToken } from '../services/auth-service.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
|
||||
import { getMessage } from '../i18n/messages.js';
|
||||
|
||||
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/register — Create a new user and organization
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/register', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email, password, display_name, org_name } = body;
|
||||
|
||||
const result = await registerUser(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.JWT_SECRET,
|
||||
email,
|
||||
password,
|
||||
display_name,
|
||||
org_name,
|
||||
);
|
||||
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/login — Authenticate and receive a JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/login', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const result = await loginUser(c.env.ELYDORA_DB, c.env.JWT_SECRET, email, password);
|
||||
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/auth/me — Get current user profile (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.get('/me', authMiddleware, async (c) => {
|
||||
const userId = c.get('actor');
|
||||
const user = await getMe(c.env.ELYDORA_DB, userId);
|
||||
|
||||
return c.json({ user }, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/refresh — Issue a new JWT (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/refresh', authMiddleware, async (c) => {
|
||||
const now = Date.now();
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: c.get('actor'),
|
||||
org_id: c.get('org_id'),
|
||||
role: c.get('role') as RbacRole,
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
c.env.JWT_SECRET,
|
||||
);
|
||||
|
||||
return c.json({ token }, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/token — Issue an API token with custom TTL (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/token', authMiddleware, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { ttl_seconds } = body;
|
||||
|
||||
if (ttl_seconds !== null && ttl_seconds !== undefined) {
|
||||
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await issueApiToken(
|
||||
c.get('actor'),
|
||||
c.get('org_id'),
|
||||
c.get('role') as RbacRole,
|
||||
c.env.JWT_SECRET,
|
||||
ttl_seconds ?? null,
|
||||
);
|
||||
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
export { auth };
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Epoch routes — retrieve epoch root records with optional TSA anchors.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import * as epochService from '../services/epoch-service.js';
|
||||
|
||||
const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All epoch routes require authentication
|
||||
epochs.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/epochs — List all epochs for the organization
|
||||
// ---------------------------------------------------------------------------
|
||||
epochs.get('/', requireRole('readonly_investigator'), async (c) => {
|
||||
const orgId = c.get('org_id');
|
||||
const result = await epochService.listEpochs(c.env.ELYDORA_DB, orgId);
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/epochs/:epoch_id — Retrieve an epoch root
|
||||
// ---------------------------------------------------------------------------
|
||||
epochs.get(
|
||||
'/:epoch_id',
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const epochId = c.req.param('epoch_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await epochService.getEpoch(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_EVIDENCE,
|
||||
epochId,
|
||||
orgId,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { epochs };
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Export routes — create and retrieve compliance export jobs.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { CreateExportRequest } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import * as exportService from '../services/export-service.js';
|
||||
|
||||
const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All export routes require authentication
|
||||
exports_.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/exports — List all exports for the organization
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.get('/', requireRole('compliance_auditor'), async (c) => {
|
||||
const orgId = c.get('org_id');
|
||||
const result = await exportService.listExports(c.env.ELYDORA_DB, orgId);
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/exports — Create a compliance export job
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.post(
|
||||
'/',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const body = await c.req.json<CreateExportRequest>();
|
||||
|
||||
if (!body) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.bodyRequired' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await exportService.createExport(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_QUEUE,
|
||||
body,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 201);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/exports/:export_id — Retrieve export status + download URL
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.get(
|
||||
'/:export_id',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const exportId = c.req.param('export_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await exportService.getExport(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_EVIDENCE,
|
||||
exportId,
|
||||
orgId,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/exports/:export_id/download — Stream the export file from R2
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.get(
|
||||
'/:export_id/download',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const exportId = c.req.param('export_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
// Fetch the export record
|
||||
const exportRecord = await c.env.ELYDORA_DB
|
||||
.prepare('SELECT * FROM exports WHERE export_id = ? AND org_id = ?')
|
||||
.bind(exportId, orgId)
|
||||
.first<{ status: string; r2_export_key: string | null; query_params: string }>();
|
||||
|
||||
if (!exportRecord) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'export.notFoundById', params: { id: exportId } });
|
||||
}
|
||||
|
||||
if (exportRecord.status !== 'done' || !exportRecord.r2_export_key) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.notYetComplete' });
|
||||
}
|
||||
|
||||
const object = await c.env.ELYDORA_EVIDENCE.get(exportRecord.r2_export_key);
|
||||
if (!object) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'export.fileNotFound' });
|
||||
}
|
||||
|
||||
// Determine file extension from the stored query_params
|
||||
let ext = '';
|
||||
const qp = exportRecord as { query_params?: string };
|
||||
if (qp.query_params) {
|
||||
try {
|
||||
const params = JSON.parse(qp.query_params);
|
||||
ext = params.format === 'pdf' ? '.pdf' : '.json';
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', object.httpMetadata?.contentType ?? 'application/octet-stream');
|
||||
headers.set('Content-Disposition', `attachment; filename="export-${exportId}${ext}"`);
|
||||
|
||||
if (object.size !== undefined) {
|
||||
headers.set('Content-Length', String(object.size));
|
||||
}
|
||||
|
||||
return new Response(object.body, { headers });
|
||||
},
|
||||
);
|
||||
|
||||
export { exports_ };
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* JWKS route — public key discovery endpoint.
|
||||
*
|
||||
* Returns the JSON Web Key Set containing the Elydora server's public
|
||||
* keys used for signing attestation receipts (EAR) and epoch roots (EER).
|
||||
* External verifiers can use these keys to independently validate
|
||||
* signatures without contacting the Elydora API.
|
||||
*
|
||||
* This endpoint requires NO authentication (per OpenAPI spec and RFC 7517).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { JWKSResponse } from '../shared/index.js';
|
||||
import { deriveEd25519PublicKey } from '../utils/crypto.js';
|
||||
|
||||
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
|
||||
// ---------------------------------------------------------------------------
|
||||
jwks.get('/', async (c) => {
|
||||
// Derive the public key from the server signing key
|
||||
const publicKeyBase64url = await deriveEd25519PublicKey(c.env.ELYDORA_SIGNING_KEY);
|
||||
|
||||
const response: JWKSResponse = {
|
||||
keys: [
|
||||
{
|
||||
kty: 'OKP',
|
||||
crv: 'Ed25519',
|
||||
x: publicKeyBase64url,
|
||||
kid: 'elydora-server-key-v1',
|
||||
use: 'sig',
|
||||
alg: 'EdDSA',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
c.header('Cache-Control', 'public, max-age=3600');
|
||||
return c.json(response, 200);
|
||||
});
|
||||
|
||||
export { jwks };
|
||||
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user